Compare commits

...

903 Commits

Author SHA1 Message Date
Tanish Manku d873a1fada Soft brushes paint soft strokes and thin lines stay even: a Gaussian-like tip and area coverage for tiny tips
Brush phase 1 (owner-approved option A of the brush audit):

- Soft tips fall off as (1 - t²)⁴ from the hard core to the edge, a Gaussian-like curve that
  tracks exp(-4.6 t²) but is exact on every platform and reaches 0 at the edge with a flat tangent.
  The old smoothstep kept so much of the tip near full strength that overlapping dabs (Flow builds
  up, as in Photoshop) turned a soft stroke's edge to half the tip's width. It now keeps about two
  thirds of it, at the default 10 % spacing, with no ripple along the stroke.
- Computed tips under 3 px radius are rasterised by covered area (a 4×4 grid per pixel, each
  sample anti-aliased over its quarter pixel) instead of at pixel centres: a 1 px dab's total
  coverage no longer swings 1.00–1.17 with its sub-pixel position, so thin lines don't bead.
- The default spacing stays 10 %. Photoshop's documented 25 % beads soft strokes with either
  curve; it waits for Photoshop-authored reference strokes.
2026-10-09 16:23:28 -07:00
Tanish Manku a680ed22c9
Brush strokes carry their timing: the first moves count, smoothing feels the same on any device, a held airbrush builds up (#2110)
Brush phase 3a (owner-approved brush overhaul):

- Every stroke point now carries a time (`timeMs`). egui's pointer events have none, so a frame's
  samples are spread evenly between the previous point and the frame time. Speed spacing, airbrush
  Build-up and Stroke Catch-Up worked from scripts but never from the canvas, because every point
  had time 0. The live stroke and the commit get the same times.
- A stroke started on the press takes its moves from the press on. The moves before egui
  recognised a drag (its 6 pt click distance) were dropped, which gave every stroke a straight
  start and lost the pen's pressure ramp-in.
- Smoothing pulls by stroke time (16 ms steps, fractional below that), not once per input event,
  so a 1000 Hz mouse and a 60 Hz pen feel the same. Without Stroke Catch-Up a paused pointer stops
  the brush, as in Photoshop.
- A pointer held still keeps the stroke's clock running only while the stroke changes with time
  (airbrush Build-up, or smoothing still catching up): it repeats the held point every 16 ms and
  asks for repaints, then goes idle.
2026-10-09 15:08:25 -07:00
Sir Starch f4c1048ead
Chisel bevel: facets are flat at any edge angle (#1926) (#2006)
* Layer styles: chiselled bevel facets are flat at any edge angle (#1926)

Chisel Hard and Soft shaded the facets of an edge at 30, 60, 120 or 150
degrees in regular cross-stripes (a comb along the edge), while 0, 45, 90
and 135 degree edges were clean. The height of a chisel is a linear ramp of
the distance to the edge, and that distance was measured to the nearest
pixel centre of a thresholded mask: an oblique edge is a pixel staircase,
so the distance ripples with its period (0.19 px at 60 degrees), and the
shading is a gradient of it, scaled by depth.

Chisel bevels now measure to the edge itself. A sharp anti-aliased edge
pixel (partly covered, touching an empty and a full pixel) places the edge
by its coverage along its own normal (edge_offset inverts the area a
straight edge cuts off a pixel), and a pixel measures to the line of the
edge pixels around the foot of its perpendicular, not to the centre of the
nearest one (edge_distance). The ripple drops to 0.01 px. Hard-edged masks
and soft ramps have no sharp edge pixel and give exactly the plain fields.

The GPU compositor takes the bevel distance fields from the CPU through
new FieldKind::BevelInside / BevelOutside (the glow fields are unchanged);
distance_field takes the reach so values past it are not refined, and the
band halo of the bevel fields grows to reach + 9.

* Chisel bevel: edge normals from a line fit, exact at hard stretches (#1926)

A follow-up to the previous commit, from measuring how far it was from the
best it can do. The 3x3 coverage gradient gives an edge's direction to 1-3
degrees, and the error is systematic, so every distance tilted the same way
and left a 0.010-0.017 px ripple. With the true normals the ripple is 0.002
to 0.004 px, so the normal was the limit.

refine_edge_normals fits a line through the edge points (pixel centre moved
to its edge) of the sharp pixels within 3 px that agree to 25 degrees, and
takes the line's normal where the points really are on a line (spread along
it at least 0.7 px, off it at most 0.1 px). Corners and tight curves keep the
gradient's normal. On straight edges it reaches the ideal-normal floor.

The inside field now seeds from the empty pixels, as the plain field does,
and measures to the sharp pixel next to the nearest empty one, capped by the
plain distance (which never falls short of the true one). A circle's pole,
or a rounded rectangle on whole pixels, has a stretch of edge exactly on a
pixel boundary with no sharp pixel; the distance to the sharp pixels alone
was up to 0.29 px too long there. A mask without sharp pixels still gives
exactly the plain field.

Measured on anti-aliased shapes: ripple on straight edges 0.0105 -> 0.0038
(0.0172 -> 0.0021 at 75 degrees); a disc of radius 70, inside, worst error
0.29 -> 0.02. The band halo of the bevel fields is reach + 9 and the banded
test now sees a halo that is too small (the old one passed at reach + 3).

* Chisel bevel: sparse sharp-edge storage, no full-map copies (#1926)

Measured before sending the fix: the new
bevel fields made a bevel size change in the GPU canvas about twice as slow
(12 MP: 84 -> 161 ms per step), and nearly all of a step is the CPU effect
maps. The cost of the normal refinement was memory, not arithmetic: a
Vec<Option<SharpEdge>> is 16 bytes for every pixel, refine_edge_normals
copied the whole map twice for a few thousand edge pixels, and
local_coverage cloned the whole map and read nine pixels for each.

SharpEdges keeps a 4 byte slot per pixel and a short list of the edge
pixels. The candidates are found by a row by row pass over which pixels are
covered (an edge pixel is covered and has an empty neighbour), and the
coverage is worked out where it is needed by the same formula as
local_coverage. The refinement works on the list. Nothing else changes:
the fields are bit-identical (FNV fingerprints of 27 maps at 5 reaches and
of whole bevel_maps at 3 sizes, 216 of 216 equal), and the rendered PNGs of
the issue scene and a realistic scene are byte-identical.

Release, 12 MP ellipse, size 8, one thread: new fields 408 -> 351 ms (old
fields 290); the sharp pass 48 -> 19 ms and the PCA step 32 -> 1 ms.
GPU canvas, ms per size change, main / before / after: 12 MP ellipse
84 / 161 / 96, 300 stars 122 / 246 / 166, text 91 / 184 / 139, 36 MP
ellipse 295 / 522 / 336. What is left on edge-heavy content is the
neighbour search and edge_distance, which this commit does not touch.
2026-10-09 14:22:21 -07:00
zenjabba 3bc49f5e1a
Fix Smart Object conversion of selected layers (#1079) (#2052)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 14:03:16 -07:00
kayrockscreenprinting 0493d06aaa
Edit › Stroke: whole opaque pixels, Center like Photoshop, and strokes on the canvas edge (#2025)
* Edit > Stroke: whole opaque pixels; Center puts the odd pixel inside; canvas edge strokes

Center expanded and contracted the selection by w/2, so odd widths left
half-transparent pixels on both sides (a 1 px stroke was a 2 px band at
half opacity), and a selection edge on the canvas border got no stroke
(Select All, Stroke Inside drew nothing). As in Photoshop, widths round up,
Center puts ceil(w/2) inside and floor(w/2) outside, and the mask is padded
so the canvas border is a selection edge.

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

* Edit > Stroke: a hard-edged selection strokes hard, matching Photoshop's curves

Photoshop's 6 px Outside stroke of a 14 px circle selection made without
anti-aliasing is reproduced exactly when a pixel is stroked whenever its
centre is less than width + 1 from a selected pixel's (our expand gave the
6..7 px fringe partial alpha). For a hard selection the band is hardened;
soft selections keep their soft edges.

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

* Edit > Stroke: the inside of a hard stroke matches Photoshop on curves

Photoshop's 3 px and 5 px Inside strokes of a 14 px hard circle, and its 6 px
one filling the circle, fit one rule: inside, a pixel is stroked when its
centre is less than width + 1/2 from an unselected pixel's (the outside rule
is width + 1). Straight edges are unchanged.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 14:01:07 -07:00
kayrockscreenprinting 1b8a68d45b
Edit › Fill and Edit › Stroke: the colour opens the Color Picker (#2019)
* Edit > Fill: Color... and its swatch open the Color Picker

Choosing Contents: Color... opens PhotoCraft's Color Picker (as Photoshop's
Fill does), and the colour swatch opens it too instead of egui's compact
popup. OK sets the Fill colour, not the foreground; Cancel leaves it.

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

* Edit > Stroke: the colour swatch opens the Color Picker

A dialog body asks for the Color Picker on its colour field with
color_picker_ui::request; Fill and Stroke share it. OK sets the dialog's
colour, not the foreground.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 14:00:31 -07:00
David Mann 51f9abaad7
Brushes: decode imported tips on demand, not at start (#1843) (#1949)
Opening the preset store decoded every imported tip at full resolution, and
each tip then lived twice: in the session's library and in the store's
snapshot of the last sync. A generated 600-brush library (16-bit tips,
800-2000 px, 2.27 GB decoded) cost about 4.5 GB after import and again after
every restart.

Library presets now hold their bitmaps as StoredTile references with a small
saved preview (at most 96 px): photocraft-paint gains TipShape::Stored and
Pattern::Stored (rendered with the full bitmap when loaded, else the preview),
and the store writes each tip's size and preview with its group. Opening the
store reads previews only; once a group is written its presets drop their full
bitmaps. A tip is decoded when its brush is used (picking a preset, a stroke
naming one, exporting) into a 256 MB least-recently-used cache. A loaded stored
tip serialises as `sampled`, so tool presets, actions and brush.get keep the
same JSON. Stores written before this change have no previews: their tips are
decoded once at the next start, eight at a time, and the groups rewritten. A
missing or corrupt tip file is now reported when the brush is used.

Measured with crates/engine/examples/abr_memory.rs on the 600-brush library
from crates/io/examples/gen_abr_library.rs (Windows, release):
after import 4565 -> 38 MB working set, after restart 4551 -> 20 MB,
after using 5 brushes 4560 -> 37 MB; store open 1.47 s -> 0.05 s.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 14:00:12 -07:00
zenjabba 20a5f193f8
docs: describe actual raster format depths and decode limits (#520) (#2050)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 13:59:13 -07:00
Roaldo Sulejmani 424a5d492f
fix: remove Photoshop from user-facing strings (#1985) (#2008)
The app still showed the Adobe Photoshop name in several user-visible
strings. Per the product rule (user-facing text says PhotoCraft only),
none of these mention Photoshop now.

UI labels, tooltips and status messages:
- New Document preset "Default Photoshop Size" -> "Default PhotoCraft Size"
- "All shortcuts reset to Photoshop defaults." -> "All shortcuts reset to
  PhotoCraft defaults."
- "Import Photoshop brushes (.abr)" -> "Import brushes (.abr)"
- smart-filter fallback name "Photoshop filter" -> "Smart filter"
- File > Save As .psd/.psb filter "Photoshop" -> "PSD Document"

Errors and warnings:
- "this Photoshop filter isn't implemented in PhotoCraft" -> "this smart
  filter isn't implemented in PhotoCraft"
- smart_map: "has no PSD equivalent", "not a recognized filter", "its
  filter data is missing/unreadable", "this filter has no PSD equivalent"
- tiff_layers: "the PSD layer data...", "the PSD image resources..."
- multichannel: "32-bit Multichannel isn't supported"
- abr: "not a readable brush file (.abr)"
- gradient import: "not a readable gradient file (.grd)"

Translations: updated the changed tl! sources in all 16 catalogs and
dropped the stale "Load Photoshop brushes (.abr)" entries.

Installer / OS metadata: de-branded the nfpm description, the PSB MIME
comment, the AppStream summary/description, the macOS UTType
descriptions, the Windows ProgId Description and the desktop launcher's
Keywords. The Photocraft.PhotoshopDocument ProgId and the
image/vnd.adobe.photoshop MIME/UTType stay: machine identifiers,
changing them hurts file fidelity and Open-with registration.

Command params docs (agent-facing): dropped Photoshop from the
type_cmds kernPair text, the layer_menu_cmds Advanced Blending defaults
and the mask_view_cmds backslash-key text. The "photoshopManages" wire
value stays. Comments, format identifiers and the "photoshop" theme
alias are out of scope and unchanged.

The macOS native-menu shortcut resolution messages no longer say "as in
Photoshop" (Hide/Minimize).
2026-10-09 13:59:07 -07:00
David Mann 27906c05c1
Compose: bound effect memory on large documents (#1909) (#2076)
A layer with Transparency Shapes Layer off takes the whole canvas as its
effect region, so on a 1920 x 103675 page each such stroked layer built
~2.4 GB of effect maps, and a render held them for every layer at once.
Every edit's damage rect rebuilt them: 13 s and 11+ GB for three layers,
past 24 GB for ten.

When such a layer's effects are local (no gradient strokes, Precise
glows, chiselled bevels or noise) and it is composited within the
rectangle being rendered (each band of render_bands, or a damage rect),
build its maps over that rectangle padded by twice the effect reach, and
let them go when the next band starts. Composited over anything wider (a
group's effect shape renders its children over the group's whole region)
it keeps the whole region. The pixels are unchanged: a test renders every
local effect kind, masked and unmasked, inside a group with far-reaching
effects and clipped to a base with a wide glow, at 8/16/32 bits, in bands,
parts and tiles from a cold effect cache, and compares bit for bit with
the whole render.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:58:01 -07:00
zenjabba 102e7ea891
fix(ui): apply distinct workspace preset layouts (#1120) (#2051)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-09 13:57:56 -07:00
Alex 523d9257a8
Match New Document to clipboard in control API (#2045) 2026-10-09 13:56:24 -07:00
David Mann 6fba13cd1d
History: Delete current state and New Document from the panel footer work (#1117) (#2039)
The History panel footer's trash and file-plus buttons discarded their
clicks. They now dispatch two new engine commands:

- history.deleteState {"state"?}: drops the state and every later one,
  as Photoshop's linear history does; the document returns to the state
  before and the deleted steps can't be redone. The oldest state can't
  be deleted (button disabled).
- history.newDocument {"state"?,"name"?}: a new document holding the
  document at that state, named after the state, with a fresh history.

Snapshots aren't implemented, so Create new snapshot is shown greyed.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:56:19 -07:00
David Mann 7567347b29
Dialogs: the first value is focused and selected; values persist (#1757) (#2040)
Like Photoshop, a schema value dialog (Select > Modify, Filter, Image >
Rotate Canvas > Arbitrary, ...) opens with its first number field focused
and its text selected, so typing replaces it and Enter applies it. It
also reopens with the values it was last applied with, kept across
restarts in prefs.dialogs and validated against the command's spec on
the way back in (ranges clamped, unknown choices dropped). Cancel and
failed runs remember nothing. Dialogs that show document state (Global
Light, Color Table, Proof Setup, Assign/Convert to Profile) and Define
Pattern keep their current behaviour.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:56:14 -07:00
David Mann 51598bb061
Text: fall back to Thai-capable fonts for missing glyphs (#1909) (#2030)
Thai typed in a font without Thai glyphs (any newly chosen font, which
drops the PSD PostScript name that found the original Thai face) fell
through the fallback stack to .notdef: none of the candidate families
cover Thai on Windows (Segoe UI), and Arial Unicode MS is rarely
installed. Add the platform Thai families (Leelawadee UI / Leelawadee /
Tahoma, Thonburi / Sukhumvit Set, Noto Sans Thai) to the candidates,
ahead of the CJK and last-resort fonts, as Photoshop's missing glyph
protection substitutes an installed font. parley picks the fallback per
cluster, so base consonants and their marks stay in one font.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 13:56:09 -07:00
Dan Hiorns 9966b534cd
About window: fixed table headers, aligned columns, fitted tabs (#2000)
Help > About > Contributors and Models: the table header stays put while rows scroll, columns are measured once so sorting no longer shifts them, counts are right-aligned and every column fits the 700 pt window. The grab bag drops its stray separators and spaces rows evenly, and Models shows <1% for a model with commits and is as tall as its rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:36:44 -04:00
Simon 72cabac616
Layers panel: the scroll wheel over the blend-mode menu steps through the modes (#1961)
The wheel over the Layers panel's blend-mode dropdown (closed or open) steps to the next or previous mode, one per notch and one undo step each, stopping at the ends, as in Photoshop. Fractional wheel deltas add up to whole notches, and the wheel there no longer scrolls the panel (#1747).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:34:01 -04:00
optimalbytee cc0441f9ae
Save As: the document takes the saved file's name (#2018)
A successful Save As (UI, automation or headless) renames the document to the written file, so its tab and title stop saying Untitled. The name is file identity, not a history step: undo and redo keep it. Failed writes, Save a Copy and exports leave the name, path and dirty state alone, and a deferred save no longer steals the selected tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:30:52 -04:00
Raghav 45b22e7403
GPU: gradients with coincident stops fall back to the CPU compositor (#2005)
The 4096-entry ramp LUT interpolated across a hard stop, showing grey where the CPU export is
white. Gradient fills, Gradient Maps and gradient effects with coincident colour or opacity stops
now return Unsupported so the canvas uses the CPU oracle. Fixes #974.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:30:42 -04:00
Elvis33LE 08ca445782
DNG: deflate compression 8 with the TIFF predictors (#1971)
Integer DNGs with zlib-compressed strips or tiles (compression 8) now decode, with TIFF predictor 1
and 2 (wrapping at the sample's container). Output is capped at the tile size; floating-point
predictor 3 and lossy DNG keep their unsupported errors. flate2 was already a workspace dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:29:05 -04:00
shmuelbiller1 f1413ea12e
Outer Glow stays behind a see-through fill (#1964)
Outer Glow now gets the same fill-dependent knockout as Drop Shadow, on CPU and GPU, so a Fill 0%
shape with a glow no longer paints the glow across its interior. Fixes #1953.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:26:38 -04:00
Brandon Thomas 11a2b72455
Prefs audit: Export Clipboard reads as wired again (#2069)
The prefs_usage scan treats everything after a file's first #[cfg(test)] as test code; the channel-tint tests from #1986 sat above export_os_clipboard and hid its read. The test module moves to the end of ui-egui's lib.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:23:11 -04:00
Unemars 9da40a1c7a
Quick Selection: Photoshop's behaviour, measured and fitted (#1966)
Quick Selection is now one min cut around the brush footprint (Boykov & Jolly 2001; coarse to fine for
large windows, Lombaert et al. 2005), with costs fitted to Photoshop 25.4's masks measured as a black box
on 105 synthetic and public-domain cases (mean IoU 0.94 synthetic, 0.83 paintings), at Photoshop's
512x512 working-resolution rule. Replaces the colour-model flood, which selected far too much.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:21:27 -04:00
Nigel Ihza Firdausy bdbd43e63f
Color Lookup: Load 3D LUT opens the native file picker (#1947)
The Color Lookup properties' Load 3D LUT… entry opens the platform file picker (filtered to .cube, .3dl and .look, on desktop and web) and embeds the chosen LUT into that adjustment layer, replacing the typed-path field. A cancelled or invalid pick leaves the layer unchanged (#1749).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:20:13 -04:00
Nigel Ihza Firdausy a4fbb1245d
Levels applies each channel before the master, like Curves (#1929)
Levels and Curves composed their master and per-channel records in opposite orders (#975). The
Photoshop-authored corpus decides it: channel first, then master. Levels now does that, bringing
psd-tools levels_rgb.psd from 0.447 to 0.012 max error; Curves (curves_rgb.psd passes) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:19:29 -04:00
Gert Pellin - GPE 24435de7bb
Canvas: say when the desktop takes an Alt-click (#2003)
Desktops that move windows with Alt+drag (Cinnamon, MATE, Xfce, older KDE) grab Alt+left presses, so
the Alt Eyedropper and Clone Stamp source never got the click. The canvas now spots the grab and says in
the status bar what happened and how to get Alt-click back. Fixes #1658, #1659.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:14:38 -04:00
Nigel Ihza Firdausy 748821864e
Panels: Close and Close Tab Group on a dock tab's context menu (#1924)
Right-clicking a dock tab offers Close (hides just that tab; the last one hides its group) and Close Tab Group, as in Photoshop. Hidden tabs are kept by name in the dock layout, so they survive theme switches and workspace saves, and the Window menu brings a tab back (#1753).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:14:10 -04:00
Max Murphy 27263bb16d
Canvas: zoom is physical at any display scale (#1978)
View zoom now means device pixels per document pixel, so 100% shows one image pixel per physical
display pixel on a scaled (125%, 200%) display, as in Photoshop. Fit, Actual Pixels, pan/zoom anchoring,
scrollbars, the navigator, the pixel grid and every hit tolerance go through canvas_ppp / point_zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:10:49 -04:00
David Mann 32c918aaee
Crop tool: overlay options (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O (#2026)
The Crop options bar's overlay button opens Photoshop's overlay menu: six overlays, Auto/Always/Never
show modes, and O / Shift+O to cycle the overlay and its orientation. The default is Rule of Thirds with
Auto Show Overlay, as in Photoshop. ui.set takes cropOverlay, cropOverlayShow and cropOverlayOrientation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:06:27 -04:00
Krzysztof Cieślik 0bf7a1e15f
Preferences: wire Large Tabs, Export Clipboard and Show Channels in Color (#1986)
Large Tabs makes the document tab strip taller (34 px) with larger type; turning off Export Clipboard stops copies being mirrored to the OS clipboard; Show Channels in Color tints the Channels panel thumbnails (RGB from black to the primary, CMYK from white to the ink). Settings that do nothing: 48 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:44 -04:00
Brandon Thomas 9b0c6c4d74
PSD import: a pattern-filled shape opens as a pattern fill layer (#2059)
Since #1917 the exporter writes pattern fill pixels; a vector-masked pattern fill then reopened as a
shape layer covering the canvas, failing the corpus round-trip floor (adjustment-fillers.psd).
Pattern-filled plain shapes now import as pattern fill layers with their vector mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:59:48 -04:00
David Mann b8fe7dd5ee
Crop tool: arrow keys nudge, X swaps orientation, W × H readout (#1958)
With a pending crop frame, the arrows move it 1 px (Shift 10 px) instead of the selection or layer, X
swaps it between portrait and landscape about its centre (with the options-bar ratio), and the frame's
W x H shows beside the pointer while drawing or resizing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:59:16 -04:00
Nigel Ihza Firdausy 04b9db61e9
Filter dialogs remember their last applied settings (#1944)
A schema-built filter dialog reopens with the values it was last applied with, as in Photoshop. Only schema-valid numbers, choices and booleans are kept (no paths, document indices or free text), and a failed or cancelled dialog saves nothing (#1757).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:57:03 -04:00
David Mann d0119dedbb
Crop tool: a pending crop stays with its document (#1957)
A pending crop frame belongs to the document it was drawn on: New, Open, a tab switch or closing it
cancels the frame, so it never shows on or crops another document. While a crop is pending, the menus
Photoshop greys are greyed (checked item by item in Photoshop 27.11), and Image > Crop commits it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:54:01 -04:00
Nigel Ihza Firdausy a82408e3e2
Actions: Play runs the first action when none is selected (#1990)
The Actions panel's Play button falls back to the first action when no row (or a stale row) is selected, as Photoshop does, and a failed playback request now shows in the status bar instead of being dropped (#1951).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:53:33 -04:00
Nigel Ihza Firdausy 25e349b048
Dialogs: the whole title band drags the window (#1945)
A dialog's drag handle spans the full title row plus a few pixels of padding around the title, so grabs slightly beside, above or below the title text move the window (#1921). The title keeps its height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:50:27 -04:00
Nigel Ihza Firdausy 765495e53b
Free Transform: rotation cursor and live angle readout (#1948)
Just outside a corner the pointer becomes a curved rotate arrow, and while rotating the angle is shown
next to it. Distort and Warp keep their cursors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:50:07 -04:00
Nigel Ihza Firdausy c02ffa9755
Spot Healing: Proximity Match samples the original pixels (#1968)
Proximity Match copied its translated patch within the buffer it was writing, so overlapping offsets
re-copied fresh output and left streaks. It now reads from the pre-heal pixels, with checked bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:46:58 -04:00
Reynold Hu 60bce1894b
File Handling: wire Lowercase Extension through Save As (#1941)
Preferences > File Handling > Lowercase Extension is live: with it on (the default) a save path's extension is lowercased (CAT.PSD saves as CAT.psd); off keeps the user's spelling. Settings that do nothing: 51 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:45:20 -04:00
LloydNicholson be446f89d3
Transparent PNG, TIFF and other flat images open as Layer 0, not Background (#1913)
A flat image with transparency (alpha channel or tRNS) opens as a normal, unlocked Layer 0, as in
Photoshop; opaque images still open as the locked Background. A lone Layer 0 saves back to PNG natively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:43:14 -04:00
Reynold Hu 429b9fc6a5
Type: wire the Use Escape to Commit preference (#1927)
Preferences > Type > Use Escape to Commit is live: on (the default) Escape commits the type session as before; off, Escape cancels it like the Cancel button, as in Photoshop. Settings that do nothing: 52 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:42:05 -04:00
Reynold Hu 23c56c659e
Type: wire Fill New Type Layers with Placeholder Text (#1935)
Preferences > Type > Fill new type layers with placeholder text is live: with it off, a Type tool click starts an empty layer (removed again if committed without typing). Settings that do nothing: 53 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:38:38 -04:00
Nigel Ihza Firdausy 3eef2c6bb4
Type: imported PSD tabs render as whitespace, not a box (#1942)
A literal tab in PSD text shaped as .notdef. Layout now shapes it as a space, keeping byte, style
and caret offsets intact. Tab stops are still to come. Part of #1881.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:34:38 -04:00
Nigel Ihza Firdausy cc4a13b58d
Paint: keep stylus pressure on quick taps (#1967)
A release that egui reports as both a drag stop and a click no longer replays the click over a live
stroke (which painted a second full-pressure dab), and the click fallback now uses the pen's pressure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:34:21 -04:00
MarekSpodBiedry f885991184
Menus: a submenu with every item disabled is disabled too (#1984)
A submenu header is enabled only while some item under it can run, so File > Export, Automate, Scripts and Import grey out while a crop is pending, as in Photoshop (#1976).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:33:39 -04:00
Nigel Ihza Firdausy 54d7b07acf
Convert to Smart Object keeps a pattern fill's pattern (#1937)
The embedded document built by layer_to_smart now carries the document's patterns, so pattern fills
and pattern effects keep rendering inside the smart object and after Edit Contents. Fixes #1867.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:33:20 -04:00
Brandon Thomas ee3ebfa748
Smart contents test: match the refused path by file name (Windows CI red since #1882) (#2042)
The refusal message Debug-quotes the path, which doubles backslashes on Windows, so
contains(path) never matched there. Matching the unique file name keeps the check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:32:39 -04:00
kayrockscreenprinting 8b29d6c2cd
PSD import: warn when an adjustment layer can't be read (#1925)
An adjustment layer kept verbatim as Unsupported rendered as nothing without telling anyone. Import
now warns, naming the layer, the adjustment and (for Color Lookup) the reason. Part of #1763.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:29:29 -04:00
Reynold Hu b9f469546f
Print Size honors the Screen Resolution preference (#1923)
View > Print Size divides the document's ppi into Preferences > Units & Rulers > Screen Resolution (72 ppi by default) instead of a hard-coded 72, and the setting is no longer hidden. Settings that do nothing: 54 of 141 (#204).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:29:25 -04:00
Nigel Ihza Firdausy d6802c62c0
Proximity Match: bound search radii to the image (#1969)
best_offset clamps the search radius and ring to the image extent and rejects zero or negative radii,
so hostile values no longer overflow i32 loops or search for billions of iterations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:29:16 -04:00
Nigel Ihza Firdausy 3cae29abfa
Grid: percentage spacing and snapping use each canvas axis (#1914)
A percentage grid now spaces vertical lines by the document width and horizontal lines by its height,
so rectangular canvases get proportional cells, and grid snapping uses the same per-axis steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:27:30 -04:00
kayrockscreenprinting 2819f37033
PSD export: pattern fill layers keep their pattern in CMYK and Lab (#1917)
The PSD (and layered TIFF) exporter rendered fill layers with no patterns, so pattern fills were
written as transparent pixels; in CMYK and Lab those tiles survive and the reopened layer drew empty.
Pattern fills now render from the document's patterns. Round-trip test over 4 modes x 3 depths.
Fixes #1907.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:26:56 -04:00
kayrockscreenprinting 19256a29f5
Fill layers keep their colours in the document's mode (#1915)
New Fill Layer (Solid Color, Gradient), the Gradient tool's live fill layers, gradient fill edits and Gradients panel presets store their colours in the document's mode (Color::in_mode / Fill::in_mode), so a fill layer in a Grayscale, CMYK or Lab document composites like the same colour painted with Edit › Fill, as Image › Mode already converted them. Fixes #1905; tests at 8, 16 and 32 bits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:23:07 -04:00
khhjoe 01bdf39f1b
gpu: write F32_MAX exactly so the compositor shader builds on WebGPU (#1982)
The decimal 3.40282347e38 is just above f32::MAX; naga rounds it but browsers' Tint rejects it, so
the web app had no GPU compositor. Now a hex literal, with a test that every decimal float literal in
the shader fits f32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:22:30 -04:00
Nigel Ihza Firdausy 3c5dc002d2
macOS: unsaved-changes prompt shows plain button labels (#1936)
On macOS the save prompt reads Save / Don't Save / Cancel without printed key letters, like the native alert; D, C, S, Return and Escape still answer it. Windows and Linux keep their mnemonics. Shortcut test from #1963 (arthurdeka).

Co-Authored-By: arthurdeka <arthurdeka@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:22:26 -04:00
jason4short b808780666
Paste: drop a stale image once the OS clipboard has moved on (#1912)
When the OS clipboard no longer holds the image we mirrored or imported (text or a file was copied
since), the session clipboard drops it, so Paste reports nothing to paste instead of a stale image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:20:38 -04:00
shmuelbiller1 3e9359071c
Native format: deflated ZIP entries read with an unbounded budget, no huge preallocation (#1956)
Reading a deflated .pcraft entry with max = usize::MAX no longer overflows the take() limit, and a
forged size in the ZIP header no longer reserves gigabytes up front (preallocation is now capped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:19:43 -04:00
Dmytro 74eacf9860
i18n(uk): refine eyedropper and colour picker wording (#1974)
Ukrainian: clearer sampling terms (Sample, Sample Size), N × N averages, Color to Alpha and the web/print gamut warnings, plus the missing Transparency/Opacity Threshold rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:18:16 -04:00
ROSSA AI 483712655f
codecs: PNG export gives the same bytes on any core count (#1932)
The parallel PNG encoder now cuts deflate bands by a fixed byte budget instead of by thread count,
so the same image and options give identical files on any machine. Test covers 1, 2, 7 and 32 threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:17:06 -04:00
shmuelbiller1 decc7715b9
PSD: Optical kerning survives a round trip (#1517)
PSD export regenerates the document Txt2 block with one text object per TextIndex carrying each run's auto-kern mode (manual, metrics, optical), which EngineData's AutoKerning can't distinguish, so Optical no longer reopens as Metrics (#1348). A kept object with unchanged text keeps the file's extras (glyph pen positions); duplicate or out-of-range TextIndex values are renumbered and capped, so a crafted file can't size the save. io corpus tests stay at their floors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:09:10 -04:00
optimalbytee 6ae2a8498c
Optimize Radial Blur sampling without changing output (#1499)
Radial Blur prepares its sample transforms once per filter (blur::radial::Plan) and shares them across output tiles, reading each bilinear tap once with channels kept together. Output is identical to the previous sampler at Draft, Good and Best, across colour models and depths (tests compare against the unchanged reference path); a failed table allocation falls back to that sampler. Measured on 24 MP RGBA8, Good, amount 1: Spin 66.1 -> 16.4 s, Zoom 19.2 -> 5.4 s (bench_radial example, docs/radial-blur-performance.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:41:52 -04:00
Nigel Ihza Firdausy d402631411
File > Export > Color Lookup Tables: export selected adjustment layers (#1493)
file.export.colorLookupTables takes scope all|selected or an explicit layers list and bakes only those top-level, visible adjustment layers, always in document stacking order; the dialog defaults to the selection when it is all exportable adjustments. Invalid selections and sizes are errors rather than silent fallbacks; LUT sizes outside 2-129 (the reader's limit) are now refused instead of clamped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:36:51 -04:00
optimalbytee d651be1393
Speed up Mosaic with portable row reads (#1524)
Mosaic sums each four-channel cell by reading whole rows once with channel sums kept in registers, in the original addition order, so the output is bit-identical (including HDR values, -0.0, zero alpha and partial source bounds); 24-38% faster on three of the four measured cell sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:26:13 -04:00
Harrison O'Connor-Hoover 9e36825d2d
Allow automation to edit embedded Smart Object contents (#1882)
Fixes #1833. Control and MCP may run Edit Contents, Convert to Layers and Save Contents on smart objects whose source is in memory (embedded, or a Photoshop UUID backed by embedded liFD data). Before any disk read the engine re-checks the source's stored path through the session's authorize gate, including nested sources re-rendered while unpacking, so external linked files stay refused; explicit layer targets work with another layer selected, and interactive linked-file access is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:24:58 -04:00
Minseo Lee 9f23d65262
Type tool: list fonts' real style names, including numeric subfamilies (#1844)
The style menu now shows each face's actual OpenType subfamily (name 17, else 2) instead of a name guessed from its weight, so fonts whose faces share one weight (Yoon's numeric 20/30/40/50) list and render distinct faces: FontDb::select_named_face registers a runtime-only alias for the exact face, while the document keeps its public family and PostScript name. Variable faces still list the standard weights their wght axis covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:12:49 -04:00
Harrison O'Connor-Hoover cd559b387a
Fix browser Save for Web downloads and stale export dimensions (#1857)
Save for Web gets a platform-writer entry point (web_cmds::save_for_web_with_writer) so the browser build downloads every image, spacer and HTML file side by side; the export is authorized before any picker opens, a failed write stops it, and only a complete export remembers its settings. Each document now opens the dialog at its own size instead of the last export's width, height and percent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:03:58 -04:00
Kostis Papadakis c0541baf6a
Raw: decode uncompressed Fujifilm RAF, Bayer and X-Trans (#1872)
RAF's header, metadata records and Fuji IFD give size, packing (16-bit, 12-bit LSB-first,
14-bit in 32-bit words), layout, black, WB, crop and exposure bias; X-Trans and other
periodic non-Bayer CFAs go through a new edge-directed demosaic. Compressed and early
SuperCCD RAF stay unsupported (preview fallback). Tests use synthetic files only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:59:07 -04:00
kayrockscreenprinting 82148e77f5
Filter previews compute off the UI thread; a stale one is cancelled once the slider settles (#1899)
In the desktop app every filter's live preview now runs on the preview worker (not just Indexed Color), so a slow one like Gaussian Blur at 1000 px no longer freezes the window. The worker hands the filter a JobCtx (Session::set_inline_job_ctx) and cancels a preview for older values once the dialog's values have been unchanged for 150 ms; during a drag the running preview is left to finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:52:24 -04:00
Gotya-OFFCIAL 4716d33372
Raw: cRAW black level without a tag; measured image areas (D800E, ILCE-7) (#1902)
Sony cRAW files with no BlackLevel tag take the 512 their tone curve gives the black
code instead of 0 (the ILCE-7 no longer develops washed out; the RX0 corpus oracle moves
to 512 too). cameras.rs gains measured image areas without a full profile: the Nikon
D800E and Sony ILCE-7 crop like the camera's JPEG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:51:43 -04:00
sunyd168 0af072e82c
SVG import: refuse deeply nested markup instead of overflowing the stack (#1901)
A pre-scan rejects element nesting past 512 levels before the XML parser recurses
(a 70 KB file of <g> aborted the process). .svgz is inflated by the importer with a
128 MB cap, so it can be scanned too and gzip bombs are refused; flate2 moves from a
dev-dependency to a dependency (already in the tree through usvg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:47:17 -04:00
Shayan Horbank a342ab8820
Add centered and transformable painting symmetry axes (#1821)
Photoshop's Vertical, Horizontal, Dual Axis and Diagonal paint symmetry presets beside the
path-based symmetry: axes start at the document centre, Transform Symmetry moves and rotates
them (Shift snaps to 15°, Enter applies, Esc cancels), and all copies merge into one coverage
pass so overlaps don't build opacity. New paint.setSymmetry command and preset icons. Fixes #1648.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:44:24 -04:00
Reynold Hu 3ae44e3911
Document tabs: Reveal in Finder and drag to reorder (#1906)
The document tab's context menu gains Reveal in Finder (a new file.revealInFinder command that reuses the smart-object reveal helper and targets the clicked tab's document), and dragging a tab sideways reorders the open documents through document.move, with a drop line, in both tab strips. Flips UI-217-6 to done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:38:40 -04:00
kayrockscreenprinting 0299162db2
MCP: tool schemas without $ref, $defs or boolean schemas (#1845)
Every tool's input schema is rewritten once at startup: $refs inlined (use-site
descriptions win), $defs dropped, boolean schemas turned into {}, with a depth cap
for recursive definitions. ui_pointer events get a concrete schema. Strict providers
no longer reject the whole tool list. Fixes #1782.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:50 -04:00
kayrockscreenprinting 63f9ca9d74
Control: shell dialogs are listed and driven as "shell" (#1811)
ui.inspect lists the open shell dialog (Workspace, Pixel Aspect Ratio, Show Extras,
32-bit Preview) with the id "shell", and ui.dialog.set/confirm/cancel accept it;
confirm goes through the automation authorizer and the dialog's shared OK. Fixes #1004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:27:46 -04:00
David Mann 147ee7efa0
Crop tool: rotate the crop frame by dragging outside it (#1879)
Outside the crop frame the pointer is Photoshop's curved turn cursor and a drag turns the frame
about its centre (Shift: 15 degree steps) with an angle readout; handles resize along the turned
axes and a double-click inside commits. image.crop takes an optional angle: Image Rotation >
Arbitrary so the frame is upright, then the crop, in one undo step. Fixes #1792.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:27:27 -04:00
Nigel Ihza Firdausy 0f2900da34
Edit > Stroke: a full options dialog with Fill's blending controls (#1477)
Edit > Stroke… from the menus opens a Photoshop-style dialog (Width, Color, Location, Blending:
Mode, Opacity, Preserve Transparency) remembering its last values; called with params it runs
directly. edit.stroke gains mode and preserveTransparency, blending through Fill's compositing
(blend_color_mask), and validates them. The Blending section is shared with Fill
(dialog_blend_ui.rs). Fixes #1463.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:21:21 -04:00
Reynold Hu 4ed4faee51
Command palette: recently run commands on an empty query (#1824)
menus::invoke records each successfully run command (deduplicated, newest first, last 8) and the palette lists them when its query is empty, falling back to the full list in a fresh session. Flips UI-217-7 to done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:21:17 -04:00
Jason Xiang 5aaf0e4aae
file.new rejects invalid parameters instead of falling back (#1878)
Unknown keys, wrong types, out-of-range sizes or resolution and unsupported
mode/depth/background now fail naming the parameter, before a floating selection is
committed or a document added; omitted fields keep their defaults. The renderer tests
now pass numeric depths (strings had silently fallen back to 8-bit). Part of #507.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:20:34 -04:00
Nigel Ihza Firdausy ffb62ec975
Pen: Ctrl/Cmd+Z undoes in-progress points (#1478)
While a Pen path is being drawn, Undo (its customisable shortcut or Edit > Undo) and Backspace/
Delete remove the last uncommitted anchor instead of undoing an earlier document step, as in
Photoshop; once the draft is empty, Undo works on the document again. Fixes #1466.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:18:00 -04:00
kayrockscreenprinting fdfa57f456
New Document starts from the last document's settings (#1869)
Fixes #1810. File > New remembers the size, resolution, mode, depth, background and display units its OK used (in the preferences' dialogs map, so they survive a restart) and starts from them next time, as Photoshop does; a clipboard image still sets the size, and corrupt remembered values are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:15:35 -04:00
Anton 66b022cddd
Web: fonts served next to the app, loaded on demand (#1875)
A host lists fonts in fonts/manifest.txt beside index.html (craft-fonts' manifest format, paths
kept inside the site); the web shell lists those families in the font menus and fetches a family
when it is picked or a layout needs it, then re-renders the affected type layers without a history
step. No manifest changes nothing; package.sh copies one in with PHOTOCRAFT_WEB_FONTS_DIR. The
style menu also lists the weights of a variable font's wght axis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:14:47 -04:00
kayrockscreenprinting 54ff207004
Image › Adjustments: apply per tile in parallel (#1800)
adjust_surface now takes the layer's tiles and adjusts each one on its own rayon task, so a large layer needs one tile's buffers per thread instead of four full-layer copies. The result is bit-identical to the single pass for every adjustment kind, colour model and depth, with and without a soft selection (tiled_adjustment_matches_one_pass_over_the_content). Adds an invert scenario to large_image_bench.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:06:46 -04:00
Jeremy 7a5b0b5d45
Canvas: the CPU canvas pixel grid skips empty pixels, like the GPU canvas (#1891)
The CPU path drew the pixel grid over the whole view, including transparent areas, while the
GPU shader only lightens or darkens pixels with content. pixel_grid.rs applies the shader's
rule to the composite of the area in view, as merged segments cached per revision and padded
region (capped at 60k segments), sharing the zoom and strength constants with the shader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:04:27 -04:00
David Mann 45c5001dfd
GPU canvas: convert changed layer tiles once each, in parallel (#1847)
Changed tiles are grouped by shared Arc and converted once each in parallel batches; a tile
shared by several coordinates (a solid fill, a cleared area, a non-zero default) is uploaded
once to a scratch tile and copied on the GPU, and new pages take the same path. 16-bit RGB and
gray tiles convert to RGBA16F through a lookup table. Select All + Fill on a 50 MP 16-bit page
refreshes in 15-19 ms instead of about 1.4 s. Fixes #1774.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:04:21 -04:00
Anton 96cdff7454
Free Transform: edge handles scale proportionally, Shift stretches one axis (#1873)
As in Photoshop CC 2019 and later, dragging an edge handle now scales both axes by the same
factor about the middle of the box (Alt: about the reference point), and Shift stretches the
one axis. Use Legacy Free Transform restores the old behaviour for edges as well as corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:04:14 -04:00
Alon 9ba843290e
Ctrl+T on a selected path transforms the path (#1871)
With Path Selection or Direct Selection and a path, Edit > Free Transform (and the Transform
items) frame the path's anchors and handles and commit one path.transform step instead of
moving pixels; it works on the Background too. Distort and Perspective, which an affine path
transform can't follow, report an error. Fixes #1770.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:04:08 -04:00
Jason Xiang a4a2583280
Layer comps: an edited layout becomes the Last Document State (#1862)
Applying a comp after ordinary edits now backs up the edited layout instead of keeping
a stale one; cycling or reapplying unedited comps keeps the original backup. The check
runs only on comp application and respects each comp's capture flags. Fixes #919.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:04:07 -04:00
David Mann 7c2bd89e6a
Canvas: edits that change no pixels (a lasso selection) don't recomposite (#1853)
Session::edit now reports empty damage when the step leaves every layer's pixels and the
document's compositing fields untouched (same_pixels: a bounded walk over the layer tree
comparing tiles by pointer), so a selection change no longer recomposites the whole canvas.
Adds a lasso_bench example. Fixes #1773.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:03:25 -04:00
rafi612 f39abb70b9
System title bar: hide the in-app mark and title, show the file in the OS title (#1903)
With Preferences › Interface › System Title Bar (Windows/Linux) the OS draws its own icon and title,
so the in-app bar drops the brand mark and centred title; the OS window title follows the active
file ("name — PhotoCraft"), sent only when it changes. macOS and the web build are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:02:53 -04:00
kayrockscreenprinting 83b0bcedbe
Schema dialogs ignore notes after a spec; Selective Color's OK works again (#1886)
parse_spec reads only the spec's leading {…} object, so notes and → {…} results after
it no longer become junk fields. Selective Color's per-range arrays stop showing as a
number field, and its OK applies again (it failed since #1790 tightened the params).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:59:25 -04:00
David Mann d7078f3623
Layers: a new empty layer no longer recomposites every layer (#1846)
step_damage pairs layers by id and steps over a layer only one side has when it draws nothing
(an empty pixel layer or group in Normal/Pass Through, no masks, effects or Advanced Blending,
not a clipping base), so New Layer, New Group and their undo/redo report no damage. Adds the
P51 perf scenario (new layer in the 150-layer document). Fixes #1771.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:59:12 -04:00
kayrockscreenprinting 21649f6030
Filter dialogs: OK keeps the preview until the filter lands (#1825)
A filter that runs as a background job no longer flashes the unfiltered image after OK: the dialog's preview is marked committing and the canvas keeps showing it (canvas::committed_filter_preview) until the job lands, fails or is cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:59:10 -04:00
Jason Xiang 175603a644
New adjustment and fill layers get a white mask (#1858)
As in Photoshop, a new adjustment or fill layer with no selection or active path gets
an enabled, linked reveal-all mask (sparse, no canvas-sized allocation), ready to paint,
in the layer's single undo step. Fixes #1768.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:48:26 -04:00
kayrockscreenprinting 71e89906ee
Dialogs: tall filter dialogs scroll; a moved dialog reopens in place (#1822)
Fixes #1829. A schema-built filter body sits in a scroll area capped to the window, so Flame's OK and Cancel stay on screen in a small window; a dialog the user drags is remembered per command (or per kind) in egui's persisted memory and reopens there, as Photoshop does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:48:07 -04:00
Neil Verschoore e7a83ca342
Add Dutch (nl) UI translation (#1877)
A complete Dutch catalog (nl.tsv) registered with the complete-menu languages (nl-NL and nl-BE
resolve to it) and in the coverage test, with the strings added on main since the PR opened and
Dutch bit-depth labels (8-bits, 16-bits).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:41:23 -04:00
kayrockscreenprinting 99e8ac2d2a
Fill: pick the Custom Pattern from its swatches (#1861)
Edit > Fill… with Contents: Pattern shows a swatch of the current pattern; a click opens a grid of every library pattern (current one outlined, names in tooltips and accessibility labels), as in Photoshop, instead of a dropdown of names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:31:53 -04:00
Dan Hiorns 13d50312bd
Brush preset grid: centre the columns and indent every row (#1883)
The preset grid (Brush Preset picker and Brushes tab) wrapped with horizontal_wrapped, which
indented only the first row and left the spare width on the right. Rows are now laid out as
whole columns with the same centred margin (grid_columns, guarded against NaN/zero sizes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:29:07 -04:00
David Mann dee737313a
Masks: \ toggles the layer mask's rubylith overlay (#1809)
New command layer.toggleMaskOverlay, bound to \ as in Photoshop: shows the active layer's
mask as a 50% red rubylith over the composite and hides it again (view state, not a history
step; the mask stays the paint target). Disabled without a layer mask; a focused text field
keeps the key. Fixes #1765.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:28:55 -04:00
Jason Xiang c645f951b0
Panels: clicking a tab of a collapsed panel expands it (#1868)
A single click on a tab of a collapsed panel group (or a pick from its overflow menu) now
expands the group and shows that tab, as in Photoshop; double-click still collapses. The tab
strip reports clicks through StripOut/CardResponse. Tested in both themes, with the
workspace locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:28:49 -04:00
Jason Xiang 941b84a748
Save As and export dialogs start beside the document (#1866)
Export dialogs suggested a bare file name, so the save dialog opened wherever the OS chose.
pick_save now joins a bare suggestion onto the active document's folder (an explicit directory
or an untitled document is left as is), and the desktop dialog starts in the suggestion's folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:28:42 -04:00
kayrockscreenprinting d4884d33fa
Rasterize Vector Mask keeps a feathered edge (#1807)
Layer > Rasterize > Vector Mask multiplied the hard vector mask into the pixel mask, dropping
the vector mask's feather (and the pixel mask's feather or density). When any of those shape
the edge, the pixel mask now becomes exactly the combined mask the compositor showed, with
feather and density baked in. Tested at 8, 16 and 32 bits against the composite. Fixes #992.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:28:36 -04:00
Anand fb23c18581
Placed layer no longer disappears when editing Smart Object contents (#1831)
Opening a just-placed Smart Object's contents switched documents while the Place transform was
pending, and end_if_left cancelled it, deleting the placed layer from the parent. When the
active document is that layer's linked child, the transform session now just ends and the
layer stays. Fixes #1797.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:28:14 -04:00
Stevan Marjanovic b81808f6bc
Double-click the Hand tool to fit on screen (#1796)
Double-clicking the Hand tool's toolbar button selects it and runs View > Fit on Screen, as in
Photoshop; a single click only picks the tool, and with no document open the double-click is
harmless. Also credits the author in contributors/people.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:27:49 -04:00
David Mann a42f7f0004
Crop tool: Enter crops to the selection's frame (#1806)
With a selection, the Crop tool's untouched default frame frames the selection's bounds, but
Enter on it did nothing (it was always treated as the no-op whole-canvas frame). It now only
skips the commit when the frame is the whole canvas. Tested at 8 and 16 bits. Fixes #1789.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:27:22 -04:00
Max Murphy dcf8ceef85
Rulers: the horizontal ruler keeps its ticks under View > Flip Horizontal (#1835)
With the view flipped, to_doc returns the ruler's ends reversed, so the tick loop ran zero
times. ruler_range normalises the visible range to (min, max) for both rulers. Fixes #1814.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:27:12 -04:00
MarekSpodBiedry d4634bb30f
Layers panel: thumbnails of tall documents fill the thumbnail height (#1805)
The layer thumbnail drew the checkerboard over the whole square cell while the texture is the
document letterboxed into it, so tall (or wide) documents showed checker bars at the sides.
The checker and image are now drawn only over the document-shaped part (channels_panel::fit_thumb).
Fixes #1762.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:27:02 -04:00
Max Murphy 1bccee1fda
Desktop app: exit with status 2 when --control setup fails (#1834)
A bad control token, token plus token file, or a missing automation root printed an
error and exited 0, so a launcher believed the control server was up. Now exits 2,
like a bad --control port; subprocess tests cover the four paths. Fixes #1816.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:26:02 -04:00
kayrockscreenprinting 749109e6fb
Define Pattern asks for the pattern's name (#1850)
Edit > Define Pattern… now opens a Pattern Name dialog, prefilled with the name the pattern would get anyway (pattern_cmds::default_name, now shared with the command so the two can't drift), as Photoshop does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:22:50 -04:00
Dmytro fee2240d59
Add Ukrainian UI translation (#1799)
A complete Ukrainian catalog (uk.tsv) registered with the complete-menu languages, with
one/few/many plurals (teens use many), uk-UA locale resolution, and the coverage test entry;
plus the strings added on main since the PR opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:18:31 -04:00
kayrockscreenprinting 86a629939a
RAW: check the orientation range before narrowing (#1839)
The five RAW readers cast the u32 Orientation to u16 before the 1..=8 check, so a LONG
65538 became 2 and rotated the image. A shared tiff::orientation checks the range on
the u32 first. Fixes #1817.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:14:28 -04:00
kayrockscreenprinting 5012e4c32f
Image Size reports the size it applied (#1836)
image.imageSize returns the document's size after the command instead of the requested
one, so Resample off (which changes the resolution only) no longer reports a size it
ignored. Tested at 8, 16 and 32 bits. Fixes #1815.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:13:12 -04:00
Max Murphy 0050f29a85
Native format: reject a leading '+' in hex pairs (#1832)
u8::from_str_radix accepts a leading '+', so "+5" decoded as 0x05; unhex now requires
two ASCII hex digits per pair and fails as corrupt otherwise. Fixes #1818.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:03:52 -04:00
Reynold Hu d4a5d974ac
File > Open starts in the last-used folder (#1889)
Open dialogs (File > Open and the single-file pickers for Place, scripts, notes and presets) now carry the most recent file's folder as initial_dir, and the desktop shell hands it to rfd's set_directory. Flips UI-217-3 to done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:03:31 -04:00
texjer e8b992a6f0
Preferences on Cmd+K, Search on Cmd+F (Photoshop's defaults) (#1900)
Edit › Preferences › General takes ⌘K and Edit › Search moves to ⌘F, as in Photoshop. Nothing
else was bound to ⌘F (Filter › Last Filter stays ⌘⌥F); the shortcut-capture tests follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:02:30 -04:00
xsvm 9b69ba89aa
image.mode.bitmap: an unknown halftone shape is a bad-params error (#1842)
HalftoneShape::from_id returns None for an unlisted id instead of silently falling
back to Round, and the command fails naming the valid shapes (part of #507).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:02:04 -04:00
Reynold Hu a14e94617b
Hue/Saturation: hue-following colour tracks on the Saturation and Lightness sliders (#1863)
The Saturation slider's track runs grey to the hue at full saturation and the Lightness track black, the hue, white; both recolour as the Hue slider moves, as in Photoshop. Flips UI-217-16 to done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:56:04 -04:00
Reynold Hu d7d5c6069c
Test the dispatch panic guard end to end; mark REL-3 done (#1803)
A cfg(test)-only test.panic command drives Session::execute into the catch_unwind
guard in jobs::dispatch; the test asserts an internal-error Err, an untouched document
and history, and a working session afterwards. REL-3 flips to done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:51:51 -04:00
Sir Starch d8d2df4fba
camera_raw_scope: serialise the GPU tests with GPU_LOCK (#1856)
Concurrent wgpu instances segfault in libvulkan with NVIDIA and lavapipe both installed; the three
GPU-harness tests in camera_raw_scope now hold a GPU_LOCK, like canvas_16f. Fixes #1855.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:51:40 -04:00
Sir Starch 9bd935498b
GPU parity tests: give the one-step tolerance f32 slack (#1852)
Two results exactly one 8-bit step apart can land a few f32 ulps past 1/255 after premultiplying
and subtracting (NVIDIA, Hue/Saturation in adjustment_layers); allow 4 epsilon of slack. Fixes #1848.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:51:36 -04:00
Reynold Hu ff1d933cb8
Scorecard: mark the BUG-205 and BUG-207 rows done; the fixes and their tests are on main (#1795)
BUG-205-1/2/3 (5a5facc, #242) and BUG-207-1/2/3 (e2444ce, #234) flip to done;
each row now names the test that covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:40:48 -04:00
Reynold Hu 7544bb1084
Scorecard: mark UI-217-2 done; the geometry round trip is tested (#1808)
UI-217-2 flips to done: b07a2e5b (#587) persists the window and dock width via eframe,
and window_and_panel_geometry_survive_a_restart asserts the round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:39:33 -04:00
BFlat 9823f4ea08
Merge pull request #1898 from storytold/community/1734-fxdrag
Layers panel: drag layer effects onto another layer, Alt-drag copies (#1734 carry)
2026-10-09 22:09:00 +07:00
BFlat 46c9b83ce1
Merge pull request #1897 from storytold/community/1706-corpus
xtask: corpus --all --update-manifest refetches every manifest (#1706 carry)
2026-10-09 22:08:56 +07:00
BFlat bf1795e490
Merge pull request #1896 from storytold/community/1462-labmix
Compose: restore the blend-space flags instead of clearing them after a tile (#1462 carry)
2026-10-09 22:08:53 +07:00
BFlat e45c79df3d
Merge pull request #1895 from storytold/community/1636-pixls
Raw corpus: real camera files from raw.pixls.us, oracle-tested (#1636 carry)
2026-10-09 22:08:50 +07:00
BFlat 9a71884244
Merge pull request #1894 from storytold/community/1745-eyedropper
Eyedropper: Sample Size, Sample layers and Show Sampling Ring (#1745 carry)
2026-10-09 22:08:46 +07:00
Unemars a01a3eb2de
Canvas zoom: Photoshop's range, ladder and ⌥-wheel step; no slide at the limit (#1718)
* Canvas zoom: Photoshop's range, ladder and ⌥-wheel step; no slide at the limit

Measured on Photoshop 25.4 (exact zoom read by script, view position by screenshot
registration):
- ⌥ + wheel: ×1.1 a notch around the pointer (was ×1.05);
- the zoom runs up to 12800 % and down to where the document's shorter side is 2 px; a wheel
  notch past a limit lands on it exactly and further notches change nothing;
- Zoom In / Zoom Out and a Zoom tool click step along 0.1 … 66.67, 100, 200 … 800, 1200, 1600,
  3200, 6400, 12800 %, from any zoom to the next rung;
- with Overscroll off, an image smaller than the view stays where zooming put it, inside the
  view, instead of being re-centred.

PhotoCraft capped the zoom at 3200 % without meaning to: the status bar's zoom field (and the
Navigator's slider) clamped it and wrote it back every frame. Past 3200 % each ⌥-wheel notch
zoomed around the pointer and the field pulled the zoom back, so the image slid towards the
pointer. All zoom paths now share `zoom_levels` (range, ladder, clamp), and the fields always
include the current zoom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Zoom: Print Size, 200 % and Fit Layers on Screen clamp through zoom_levels too

Review of #1718: `view.twoHundredPercent` / `view.printSize` and `view.fitLayersOnScreen` still
clamped to 1-6400 % on their own. They now use `zoom_levels::clamp` like every other zoom path:
Print Size at 1 ppi gives 7200 % (it was cut to 6400 %), and fitting a 1 px layer stops at
12800 %.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Wheel zoom: a notch applies on the frame it arrives (from #1490)

Folded in from @Jeremy8776's #1490, as agreed in review: zooming reads this frame's wheel
events (`Input::raw`) instead of egui's smoothed delta, so each notch is one ×1.1 step on the
frame it arrives, with no easing over the next frames (his frame-by-frame recordings of
Photoshop). The smoothed tail neither zooms nor turns into a pan when Alt is released; panning
still uses the smoothed delta. Zoom with Scroll Wheel uses the same ×1.1 notch (assumed, not
measured).

The Filter Gallery's and Liquify's own preview zooms keep their 3200 % caps, now with a comment
saying why: they aren't the canvas.

Co-authored-by: Jeremy <jeremy8776@googlemail.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremy <jeremy8776@googlemail.com>
2026-10-09 22:08:18 +07:00
buivanvu94 69f307afbb
fix(ui): autoscroll Layers panel when dragging past top or bottom edges (#1728) 2026-10-09 22:08:13 +07:00
MarekSpodBiedry 5538f3920d
Patch Tool: say to drag the selection after the lasso and after a click that moves nothing (#1715) (#1720)
The Patch and Content-Aware Move tools work in two steps: lasso the
area, then drag the selection. The first step only leaves a selection,
and a press and release inside the selection returns early because the
offset is zero, so neither step said what to do next.

After a lasso made with either tool, and after a drag inside the
selection that moves nothing, the status bar now says "Now drag the
selection onto another area". The string is translated in all 14
catalogs.
2026-10-09 22:08:10 +07:00
Juan Manuel 2b139ebd5c
inspect: report each smart object's source (#1785)
document.inspect (and photocraft-cli info) now gives every smart object a
smartSource: embedded with its file name, or linked with a file path or,
for a PSD placed layer, the Idnt uuid its duplicates share. A script can
then tell duplicates of one placed file from independent contents, e.g.
to check that every location in a mockup template has its own art.

Co-authored-by: Juan Manuel Verges Solanas <jmverges@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:08:07 +07:00
Gert Pellin - GPE 310e46e6a0
fix(ui): one Cmd+V pastes one layer (#1638) (#1743)
On macOS AppKit runs Edit > Paste's key equivalent and takes the
key-down, but winit still delivers the key-up (it forwards key-ups
while Cmd is held). The native menu fed egui a press plus a made-up
release, and clipboard_keys treats a Cmd+V release without a paste
event as an image paste (egui-winit drops Cmd+V when the clipboard
holds no text). So one Cmd+V pasted three times: the menu's press,
its synthetic release, and winit's real release.

- native_menu: a key equivalent adds only its press, inserted ahead of
  its key-up when that is already queued; winit delivers the release.
- clipboard_keys: a Cmd+V key press that reached egui marks the paste
  as seen, so its release doesn't paste again; any V/Insert release
  clears the mark (releasing Ctrl before V left it stale and swallowed
  the next image paste on Linux/Windows).
2026-10-09 22:08:03 +07:00
Gert Pellin - GPE 58289b2822
fix(codecs): read the resolution from EXIF, XMP and Photoshop APP13; write it consistently (#1691) (#1744)
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.

Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.

Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
2026-10-09 22:07:59 +07:00
BFlat e39cacd895
Packaging: Windows MSI setup wizard (folder choice, progress, version, launch on finish) (#1864)
Double-clicking the MSI now opens WixUI_InstallDir without the licence page:
Welcome, install folder (remembered for upgrades in
HKLM\Software\PhotoCraft\InstallDir), Ready, progress, and a Finish page with
a ticked "Launch PhotoCraft" box (WixShellExec, unelevated). The pages name
the full version, pre-release tag included (photocraft.en-us.wxl).
package.ps1 draws the banner and side bitmaps from the app icon, so no WiX
stock art ships. msiexec /qn still installs silently. The release and ARM64
workflows install the WiX UI and Util extensions; packaging-lint checks the
.wxl. Ported from VectorCraft (storytold/vectorcraft@aa548d2).


Claude-Session: https://claude.ai/code/session_01AP7ThRN3kpNmjsuJUerJui

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 21:33:38 +07:00
Elvis33LE adc1f76df4
Indexed Color: integer counts, palette stops and asynchronous previews (#1741)
Indexed Color accepted fractional colour counts and computed its live preview on the UI thread.
Counts are now whole numbers in 2-256 with a linear slider snapping at 8/16/32/64/128/256, and on
native builds the preview runs on one background worker that coalesces slider drags and rejects
stale results. Nearest-colour lookup uses an exact kd-tree above 64 entries (identical indices,
256 colours at 4.2 MP: 3018 -> 1061 ms). Web and inline sessions stay synchronous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:33:13 -04:00
Gert Pellin - GPE 15737e122b
Filter > Other > Color to Alpha (#1683)
A new filter that removes a chosen colour by unmixing it into transparency, as GIMP's and
Krita's Color to Alpha do: per pixel the most transparent opacity that keeps the unmixed colour
in range, shaped by transparency and opacity thresholds. It runs through run_filter (selection,
smart filters, Last Filter, one history step), floats the Background first and refuses a
transparency-locked layer; the filter dialog gains a colour parameter. Fixes #1576.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:13:42 -04:00
Claude ac9c7b057b
Merge main into #1745 (catalogs: keep both blocks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 14:08:06 +00:00
Streetblock e2bb2bd681
Share concurrent cold mask builds (#1653)
Concurrent requests for the same cold combined mask each rasterized and feathered the whole
image. A per-key OnceLock slot now lets one caller build while the others wait for the same
tiles; different keys build independently outside the cache mutex, and the result is admitted
through the byte-budgeted cache with the generation taken at lookup, so a purge mid-build still
wins. The vector mask renders sequentially inside the slot so a Rayon worker can't wait on itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:05:01 -04:00
Unemars 0d10f11733
Toolbar colour chips, Color Picker and panel footers, as in Photoshop (#1729)
The colour chips sit centred in the toolbar with Default Colors and Switch Colors above them. The Color Picker gains L/a/b radios and the out-of-gamut and web-colour warnings, each with a click to take the nearest colour. Panel footers are a full-width bar with their own mask and adjustment glyphs, keeping the right-click footer menus from #1680.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:03:37 -04:00
Streetblock 68dab88f9b
Bound mask-cache memory and include it in Purge All (#1650)
The combined-mask cache was capped only at 64 entries, so large feathered masks could retain a
lot of memory, and Edit > Purge > All left them allocated. The cache is now also bounded to
512 MiB of accounted pixel bytes (result plus the pinned input), Purge All clears it and reports
its bytes, and a purge generation stops a build that started earlier from refilling the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:00:00 -04:00
Brandon Thomas 9b1c916f42
Japanese: command names match the menu terms (#1860)
About 50 command names from #1669 now use the same word as their menu item (ゆがみ, 演算, ぼかし(ガウス) …).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:57:56 -04:00
coolvitto 214e062af8
Japanese: translate about 1,000 more UI strings (#1669)
Filter and gallery parameter names and choices, command, tool and menu names, hints, preferences, metadata fields and colour spaces, using Photoshop's Japanese terms (浅浮き彫り, チョーク・木炭, アンシャープマスク …).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:57:14 -04:00
Claude 42e60e3602
Merge #1636 into #1706: keep the pixls opt-in, fetch_all takes update
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:56:23 +00:00
Claude beb80bc5dc
Carry #1636: document test-corpus --pixls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:56:22 +00:00
Claude 819651f3c0
Merge commit 'refs/tmp/p1745' into community/1745-eyedropper
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
2026-10-09 13:55:40 +00:00
storybored379 15e35cf605
MCP: shared conventions and cancellable Render Video (#1668)
The MCP server follows the crafts' shared conventions (FilmCraft #28): core tools
incl. render_preview, titles and annotation hints, strict argument keys (-32602),
live photocraft://document and photocraft://commands resources, and -32700
recovery from malformed JSON lines. Headless Render Video writes through the
granted write root, reports progress, stays ping-responsive and can be cancelled,
removing only the files it created. docs/mcp.md describes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:54:14 -04:00
Elvis33LE 7533254791
Keep PNG text metadata through document saves (#1684)
PNG tEXt/zTXt/iTXt entries (and TIFF ASCII tags) were dropped on open with a warning. The
document now keeps them in Metadata.text, the .pcraft manifest saves them (older files still
load), and PNG/TIFF saves write them back; Metadata: None and embed_metadata=false strip them,
and PSD/PSB and palette PNG exports warn that they are dropped. XMP in a tEXt or zTXt chunk is
read as XMP too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:53:37 -04:00
Nigel Ihza Firdausy 75e6bad453
Control: ui.inspect reports the open Brush Preset picker (#1672)
ui.inspect now has brushPicker: null while closed, {pos, list} while the Brush
Preset picker is open (list is its collapsed/filter/view/renaming state), so
agents can see what a right-click with a painting tool opened. Fixes #1558.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:50:01 -04:00
Nigel Ihza Firdausy 8bc9861224
Control: ui.menu.invoke respects the modal menu policy (#1671)
A control-channel menu click could run an edit behind an open dialog (a rotation
under Image Size), which the native menu bar already refuses (#1354). The modal
gate moves to menus::modal_allows and ui.menu.invoke checks it; view navigation
stays live, engine.execute is unaffected. Fixes #1556.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:49:56 -04:00
Harrison O'Connor-Hoover 18bb712f43
Save/Close: finish layered TIFF writes before discarding documents (#1723)
Choosing Save in the close prompt for a layered TIFF only parked the save behind TIFF Options,
yet the prompt went on to close the document. The prompt now waits for TIFF Options and
advances only once the write succeeded and the document is clean; the save stays bound to the
document that asked even if the active tab changes, and Discard Layers and Save a Copy leaves
the original's path and unsaved changes alone, as in Photoshop. Fixes #1098.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:49:09 -04:00
Brennan Stehling 9fee51c308
macOS: save to SMB shares that refuse a full flush (#1639)
sync_all on Apple platforms asks for F_FULLFSYNC, which SMB shares (ENOTSUP) and some virtual
file systems (ENOTTY) refuse, so every save there failed. atomic::sync_file falls back to a
plain fsync for exactly those errors, as Apple's removefile does; the automation workspace
writes use it too. rustix is already in the lock on Apple targets. Fixes #1336.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:36:03 -04:00
Brandon Thomas 74112064cc Merge main; de: Photoshop's German eyedropper terms (Aufnahmebereich, 1 Pixel, 3 x 3 Pixel Durchschnitt) 2026-10-09 09:32:53 -04:00
Brandon Thomas 2e41caf897 ATTRIBUTION: keep the PngSuite row and one pixls row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:29:58 -04:00
Brandon Thomas 4b7db4088b Merge remote-tracking branch 'origin/main' into pr1636ff 2026-10-09 09:29:34 -04:00
Claude 0b958be52a
Merge main into #1734 (effect-row eyes from #1664 kept beside the fx drag)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:29:19 +00:00
Claude a94dd19d79
Merge main into #1745 (German catalog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:29:18 +00:00
Chandirasegaran f338454f36
Layers panel: the eyes on effect rows show and hide effects (#1664)
The eyes on a layer's Effects row and on each effect's row did nothing. As in Photoshop, the
Effects eye now hides or shows all of the layer's effects and an effect's eye just that one, each
one history step that keeps the settings, via the new layer.setEffectsVisible command. Fixes #1622.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:25:57 -04:00
kayrockscreenprinting 1284e79d23
Selective Color and Color Lookup: a wrong-typed key is an error (#1790)
Selective Color and Color Lookup params with the wrong type (a string percentage, a short range
array, an unknown method, range or interpolation) were silently ignored, so layer.setAdjustment
kept the old values without saying so. They are now BadParams errors naming the key; null still
means not given. Fixes #936.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:23:34 -04:00
Nigel Ihza Firdausy c017b119ff
Gradient tool: the options-bar swatch opens the Gradients presets (#1681)
Clicking the gradient swatch in the options bar did nothing; as in Photoshop it now opens a
popup with the Gradients preset browser, so a preset is picked without opening Window › Gradients.
Fixes #1651.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:17:21 -04:00
midasdf b5320e590e
Preferences: System Title Bar says it applies at next launch (#1677)
On Windows and Linux the System title bar checkbox now carries the existing "Applies at next
launch." note, since saving it does not change the current window. Fixes #1661.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:17:05 -04:00
Nigel Ihza Firdausy 681e7f3b89
Layers panel: right-click opens the fx and adjustment footer menus (#1680)
As in Photoshop, a secondary click on the Layers footer's Add a layer style and Create new fill or
adjustment layer buttons opens their menus too (a primary click still toggles them), and a small
arrow marks them as menu buttons. Fixes #1579.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:17:02 -04:00
Nigel Ihza Firdausy efa62efa0c
Liquify: test that Alt-reversed strokes survive replay, undo and redo (#1675)
Follow-up to #1494: Alt captured at stroke-down records the reversed tool, a
later stroke records the selected one, and the field rebuilt from the strokes
matches after undo and redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:11:07 -04:00
midasdf 40f14d25e8
Plug-ins: keep the originating error across parallel bands (#1654)
When one band failed, its siblings stopped with a generic "another band failed"
error, which could win the collection order and hide the real cause. The abort
flag now carries the first error, so every band reports it; tests cover traps,
error codes and the instruction budget across parallel bands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:11:00 -04:00
Jason Xiang c88dbd26cd
Live strokes: reject out-of-range coordinates before the preview changes (#1652)
The live brush, pencil, retouch and dab previews fed pointer batches to the
stroke renderer unchecked, so a NaN or huge coordinate could hang the path
walker. Every push now runs the shared check_coords first and rejects the whole
batch without touching the preview; tests cover 8/16/32-bit and every live tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:10:53 -04:00
Alan Rockefeller 9b4562f2a0
Open dialog: upper- and mixed-case extensions on Linux and the BSDs (#1439)
The portal and zenity match rfd's *.ext globs case-sensitively, so IMG_0001.JPG or DSC_0001.NEF didn't show. On Linux and the BSDs each letter of an Open filter extension becomes a [jJ] class; Windows and macOS keep plain extensions.
Duplicate #1682 by @switch87 took the same approach.

Co-authored-by: switch87 <switch87@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:10:04 -04:00
David Mann 347a0f9fc9
Gradient Map: pick presets from the gradient library (#1780)
The Gradient Map preset picker lists the session's gradient library by group with a swatch per preset, in the Properties panel and the dialog, as Photoshop's gradient picker does. Picking one writes its colour stops as one undoable edit.
Fixes #1588.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:09:51 -04:00
Nigel Ihza Firdausy 616f206c5d
Filter previews: only for the active document, and computable without a GPU (#1676)
A command dialog's preview is never shown in another tab, and the preview is computed even without a GPU texture, so headless tests can check it (90° Arbitrary rotation, Preview off, document untouched).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:09:45 -04:00
kayrockscreenprinting a0abb59044
View › Show › Target Path hides the path outlines (#1794)
Target Path (and Extras) now gate the work path, shape and vector-mask outlines and Direct Selection's paths; the Pen's path in progress stays visible.
Part of #1119.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:09:39 -04:00
kayrockscreenprinting cf8044bcb1
Equalize, Average and Clouds edit the targeted channel, not the layer (#1777)
These four ran through map_pixels, which always rewrote the active layer, so with an alpha
channel, the Quick Mask or a layer mask targeted they changed the layer and left the channel
alone. They now rewrite the targeted grayscale surface, as the core filters do. Fixes #935.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:07:43 -04:00
Nigel Ihza Firdausy 864d9ce991
Selection outline: trace serially while background jobs run (#1690)
A long filter could occupy every worker in Rayon's global pool, and the marching-ants trace on
the UI thread then waited for those workers, so the job's Cancel control froze. While jobs run
the outline is traced serially; a test checks it matches the parallel trace. Fixes #1554.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:35 -04:00
Duke242 2f3e1c0842
Pen: Esc ends the path and keeps it as the work path (#1787)
Esc while drawing with the Pen ended the path by throwing it away. As in Photoshop, it now ends
the path where it is, left open, and keeps it as the Work Path, the same as Return. Fixes #1769.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:22 -04:00
kayrockscreenprinting 3e1407ba68
Liquify: a stroke segment walks only the dabs that can reach the field (#1736)
A segment to a far-off point walked one dab per spacing step along its whole length (about
3e14 for a point at 1e15). Only the steps whose dab can reach a field node are walked now; a
test checks the result is bit-identical to the full walk for every tool. Fixes #937.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:57:14 -04:00
Brandon Thomas bb3108cfd2
CI: no macOS on PRs or main; macOS is built and tested by releases (#1820)
The org has 5 macOS slots in total (GitHub Team), shared by every repo's CI and
release builds, and PR CI kept them full, so release builds queued behind it.
macOS now runs only in release.yml; a release that fails on macOS is fixed and
rebuilt.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:51:18 -04:00
kayrockscreenprinting dd41598d4c
Retouch tools: test the stroke-point bound on all eleven commands (#1784)
The painting and retouch tools now share one check_coords helper for the ±1e6 point bound,
and a regression test sends far-off points to all eleven retouch commands at every depth.
Fixes #976.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:30:06 -04:00
Streetblock 3a2dda1996
German: clearer terms for 30 UI strings (#1791)
Screen is Negativ multiplizieren, Perceptual is Perzeptiv, Blend If is Farbbereich, Oldstyle is Mediävalziffern and Protect Tones is Tonwerte schützen, as in Photoshop's German UI; the font weight Black no longer reads as a colour.
Tool names keep the catalog's -Werkzeug form, Black Point Compensation is Tiefenkompensierung, and Float in Window is In Fenster schweben lassen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:27:46 -04:00
初心Yearth c63d04a09c
docs: explain Windows source build prerequisites and app-control failures (#1692)
Document the MSVC toolchain and C++ build tools workload, and how to diagnose
Smart App Control blocking Cargo or build outputs (os error 4551), with a
pointer to the packaged release. Fixes #1585.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:04:05 -04:00
Nigel Ihza Firdausy 351736ef6b
docs(mcp): grant scoped file roots in headless registration examples (#1673)
The PHOTOCRAFT_AUTOMATION_*_ROOT variables are read only by the desktop app, so
headless photocraft-cli mcp/serve need the --automation-*-root flags. Say so,
and pass explicit roots in the .mcp.json and claude mcp add examples.
Fixes #1551.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:03:58 -04:00
Nigel Ihza Firdausy 6b1075e240
docs(control): align method signatures with accepted parameters (#1674)
ui.set gains rotation, gradientBlendMode and gradientClassic in its signature
and the stale duplicate ui.set line goes; ui.menu.invoke documents params,
ui.pointer tool, app.save an optional path, and serve's doc.save tiffLayers.
Fixes #1550.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:03:30 -04:00
buivanvu94 f7d8a328e4
docs(control): Preferences Apply and OK are refused over control (#1678)
The Preferences entry said ui.dialog.apply/confirm save the dialog, but the
automation policy refuses whole-preferences updates (SECURITY.md). Document the
refusal, what happens to the dialog, and prefs.set for single safe preferences.
Fixes #1555.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:03:06 -04:00
BFlat 89922792c5
Clarify that muda is a native menu crate, not Tauri (#1786)
muda is maintained by the tauri-apps org but is standalone: AppKit menus,
no webview or JS, so it fits the no-Tauri rule. Reword the comments that
called it "Tauri's menu crate".


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 18:53:59 +07:00
Claude 8cdea53ced
Carry #1745: Current & Below keeps an active group's own contents
Document::walk lists a group before its children, so hiding everything after
the active layer hid a group's own layers and sampled transparent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 11:50:37 +00:00
Claude cc8d3c3f77
Merge main into #1462; in_blend_space restores LAB_MIX and HDR instead of clearing them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 11:49:32 +00:00
Elvis33LE 3bfaef668c pixls: usage and status text, per-file licence links, dedup, --changed for raw
The xtask usage line and the corpus status listing name --pixls and
corpus/pixls; the attribution row links each of the seven files' database
entries so the licence can be checked per file; -p raw no longer pushes
the crate into the list twice; and test-corpus --changed runs (and
fetches) the raw corpus when a file under crates/raw/ changed, matching
its CRITICAL entry.
2026-10-09 13:08:16 +02:00
Elvis33LE 5d0800df7a pixls corpus: in-house sha256 and pinned.rs, opt-in like the affinity corpus
The fetch lives in pinned.rs now, reusing its manifest discipline and the
xtask sha256 module (the sha2 dependency is gone). --pixls is opt-in: not
part of --all or the CI corpus job (CORPUS_CRATES no longer includes
photocraft-raw; test-corpus --pixls fetches and adds it), matching how the
Affinity corpus is handled. The usage line names the flag, and the
attribution row links each sample's folder in the database so its licence
can be checked per file.
2026-10-09 13:08:16 +02:00
Elvis33LE 6ce21609a2 raw corpus: real camera files from raw.pixls.us, oracle-tested
Seven public-domain files (raw.pixls.us), one per decode path: Canon
PowerShot SX50 HS as CR2 and as its DNG conversion, Nikon D3 uncompressed
NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2 (10480x7794,
crop 10368x7776), Olympus E-1 with a GRBG CFA, and the Olympus E-5 as
the documented known-unsupported packed-ORF case (still previews).

xtask grows --pixls (single-file downloads, the server redirects
/data/ to /download/), a sha256-verified manifest and a --update-manifest
path; photocraft-raw gains the corpus feature and oracle tests: format,
make/model, sensor size, CFA, black/white levels and the crop are exact,
developing yields the crop in 16-bit RGB, deterministic, with sane
range coverage.

Two real findings already documented by the tests: the PowerShot CR2 and
the RX0 ARW carry no as-shot white balance we read, so the grey-world
estimate brightens those scenes (the same camera's DNG develops blacks
to ~0). Reading the Canon and Sony maker-note WB tags is the follow-up.
2026-10-09 13:06:05 +02:00
BFlat 8e8e9f2c68
Merge pull request #1778 from storytold/community/1607-affinity
AF.3: native Affinity import (layers, artboards, vectors, text, images), carrying #1607 by @callmeBalloch
2026-10-09 18:00:53 +07:00
Elvis33LE 6cd2d61398
CR2: read ColorData's as-shot white balance as words (#1640)
* CR2: read ColorData's as-shot white balance as words

CANON_COLOR_DATA is an UNDEFINED entry: uints() hands its bytes back one
by one, but the WB_RGGBLevelsAsShot offsets count 16-bit words. Reading
word i at entry offset i*2 (file byte order, bounds-checked) finds the
quadruple at 0x47 in the corpus PowerShot SX50 HS: [1613, 951, 951, 1605]
-> [1.70, 1.0, 1.69], matching the white balance its own DNG conversion
neutralises (AsShotNeutral -> [1.58, 0.955, 1.666] through the color
matrix). With this the file's balance is used instead of the grey-world
estimate; the regression test pins the words and the missing estimate
warning. (The remaining brightness gap between the CR2 and the DNG of
the same camera is the CR2 path's exposure normalisation, to be looked
at next; the estimate was never what brightened the file.)

* cr2: drop the unused glob import in the corpus test

* CR2 WB: synthetic word-offset oracle, entry bounds, corpus test behind the feature

testgen writes ColorData as Canon does, UNDEFINED bytes that are 16-bit words
with the PowerShot's WB_RGGBLevelsAsShot at word offset 0x47, and the new
color_data_wb_as_words test checks the decoded balance in every CI run (the
corpus oracle stays behind the corpus feature and now panics when its file is
missing instead of silently skipping). word() reads stay inside the entry's
own byte count, and the shared inspect example moves back to the corpus PR.
2026-10-09 18:00:50 +07:00
McClabe c58d20bd39
Fix small caps rendering across fonts (#1644)
* fix: synthesize small caps across fonts

* fix: use native small caps when available
2026-10-09 18:00:47 +07:00
llduyll10 efca9b270d
Layered TIFF: keep blending ranges as bytes in Intel-order files (#1712)
Photoshop writes the per-channel blend-if ranges byte for byte in either byte
order (an II TIFF holds the defaults as 00 00 ff ff). The transcoder swapped
them as u32s, turning every range into ff ff 00 00, which hides every pixel of
the layer: a Photoshop layered TIFF opened with an empty canvas.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:00:43 +07:00
eric 60018dea69
fix(compose): don't clip Add and Divide at 1 in 32-bit documents (#1722)
- Linear Dodge (Add) and Divide keep values above 1 in 32-bit float documents, clipped only at f32::MAX. Integer depths still clip at 1.
- CPU: add psblend::HDR and generic::blend_rgb_hdr / blend_channel_hdr; render sets the flag per document depth.
- GPU: add the F_HDR pass flag, and compose.wgsl uses it for the same clip bound.
- Non-separable modes are unchanged.
- Tests: colour unit tests, compose depth tests, and the GPU parity test float_documents_blend_past_white.

Co-authored-by: hideoneric <hideoneric@users.noreply.github.com>
2026-10-09 18:00:39 +07:00
MarekSpodBiedry 68c2e21da8
docs: README tool count and list match Tool::ALL (#996) (#1710) 2026-10-09 18:00:32 +07:00
MarekSpodBiedry 088dbba090
fix(engine): prefs.set and prefs.reset apply the text blending gamma (#987) (#1709) 2026-10-09 18:00:29 +07:00
MarekSpodBiedry 66ab5012be
fix(compose): a Precise inner glow from the centre is no longer blank (#966) (#1708) 2026-10-09 18:00:26 +07:00
MarekSpodBiedry 57be0f58d8
fix(ui): Keyboard Shortcuts dialog rejects a malformed overrides map instead of wiping shortcuts (#956) (#1707) 2026-10-09 18:00:22 +07:00
MarekSpodBiedry 2feaf055cc
fix(xtask): the scorecard's performance summary no longer counts not-run scenarios as tracked (#947) (#1705) 2026-10-09 18:00:19 +07:00
MarekSpodBiedry 1fd600d3ad
fix(automation): bridge doc_save without path writes back like headless (#946) (#1704) 2026-10-09 18:00:16 +07:00
MarekSpodBiedry 4f33c81524
fix(io): keep a PSD Anno block that cannot be read instead of deleting it on save (#944) (#1703) 2026-10-09 18:00:12 +07:00
MarekSpodBiedry 8a31f2431b
fix(io): a CMYK Channel Mixer using the black source stays raw in CMYK documents (#940) (#1702) 2026-10-09 18:00:09 +07:00
MarekSpodBiedry 1db0dde9a4
fix(engine): Move tool Auto-Select clicks through fully locked layers (#1641) (#1701) 2026-10-09 18:00:05 +07:00
MarekSpodBiedry a4c4a02364
fix(ui): the Navigator keeps its image during a live paint stroke (#1631) (#1699) 2026-10-09 17:59:58 +07:00
MarekSpodBiedry 549baa778f
fix(ui): the Photoshop-style status bar shows messages with no document open (#1567) (#1698) 2026-10-09 17:59:55 +07:00
MarekSpodBiedry 5cfbf89917
fix(engine): layer bounds drop the area a selection transform moved pixels out of (#1097) (#1697) 2026-10-09 17:59:51 +07:00
MarekSpodBiedry 1433c3c520
fix(engine): Layer via Copy and Cut deselect, as Photoshop does (#1096) (#1696) 2026-10-09 17:59:48 +07:00
MarekSpodBiedry 3e3e6278d7
fix(control): ui.inspect reports the documented view preferences (#1003) (#1695) 2026-10-09 17:59:45 +07:00
MarekSpodBiedry 864021994c
fix(scorecard): mark the #204 preference rows done; their readers, allowlist and test exist (#1002) (#1694) 2026-10-09 17:59:41 +07:00
Claude Opus 5.5 acc27e0fd0 Merge remote-tracking branch 'origin/main' into fix/1112-lab-mix-restore
# Conflicts:
#	crates/compose/src/lib.rs
#	crates/compose/src/tests.rs
2026-10-09 06:57:07 -04:00
Gert Pellin 65f30dd9e3 feat(eyedropper): Sample Size, Sample layers and Show Sampling Ring (#1649)
The Eyedropper options bar now has Photoshop's Sample Size (Point Sample,
3 by 3 ... 101 by 101 Average), Sample (Current Layer, Current & Below,
All Layers, All Layers No Adjustments, Current & Below No Adjustments)
and Show Sampling Ring.

- engine: new `document.sampleColor {x, y, size, sampleLayer}`
  (sample_cmds.rs). Averages the square centred on the pixel, clipped to
  the canvas, alpha-weighted, in the document's colour model (Gray, Lab;
  CMYK documents use their composite RGB, since the profile round trip
  is not exact) and rounded to the document's bit depth.
- ui: eyedropper_ui.rs options bar; state.rs ToolOptions
  eyedropper_size / eyedropper_sample / eyedropper_ring (serde). The
  size is shared with the painting tools' Alt-click eyedropper, the
  Curves / Color Picker eyedroppers and the Info panel readout.
- control: ui.set eyedropperSampleSize / eyedropperSample /
  eyedropperRing; documented in control-protocol.md and the MCP schema.
- i18n: new strings in all 14 catalogs (plus the missing Russian hint
  the tl! scan had not reached).
- scorecard: TOOL-1649-1.
2026-10-09 12:28:14 +02:00
Claude 20275685b1
io/affinity: as_chunks for the mask's grey extraction (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:19:17 +00:00
Claude 30aa0f974f
affinity: as_chunks for the constant-size chunk loops (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:18:57 +00:00
MarekSpodBiedry 6ba1419286 Layers panel fx drag test: measure the drop target before the mutable drag borrow (#1719) 2026-10-09 12:18:32 +02:00
MarekSpodBiedry b692ed451c Layers panel: drag layer effects onto another layer to move them, Alt-drag copies (#1719)
Effect rows only took clicks and the fx badge was part of the layer row, so
dragging an effect or the fx badge picked up nothing (or the whole layer).

As in Photoshop, dragging the fx badge or the "Effects" row onto another
layer moves all its effects there (replacing that layer's style, like Paste
Layer Style), dragging one effect row moves just that effect (replacing an
effect of the same kind), and holding Alt on release copies instead. The
badge takes the drag the way the eye does, so clicks still select the row.
The drop is one history step through a new engine command,
layer.layerStyle.transferEffects (no menu entry, like Show All Effects).

Tests: the engine command (copy, move one, move all, undo, bad params) and a
Layers panel test that Alt-drags an effect row and drags the fx badge.
2026-10-09 11:54:19 +02:00
MarekSpodBiedry fee28f3a1b xtask corpus: pass the pinned corpus without an explicit deref (clippy) (#948) 2026-10-09 11:34:11 +02:00
Claude 2515fa7cee Linux display server: compile read_display_server on Linux (and tests) only
macOS and Windows clippy rejected it as dead code (-D warnings): its only caller in main.rs is
#[cfg(target_os = "linux")]. Also puts read_rendering_prefs' doc line back above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:12 +07:00
Claude 770f6d49b8 scorecard: regenerate for the display-server preference (141 settings)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:12 +07:00
Gert Pellin a132e37ee1 Linux: display server preference (X11 / XWayland) so file drops work
On a Wayland session dropping a file on the window does nothing: winit
0.30 (eframe 0.36) has no Wayland drag and drop (#386). The only way
out was launching with `WAYLAND_DISPLAY= photocraft`.

Add Preferences › Performance › Linux display server (auto | x11,
applies at next launch), direction 2 in #386. With x11, main.rs asks
winit for its X11 backend through eframe's event_loop_builder hook
(EventLoopBuilderExtX11::with_x11), so the window runs through XWayland
and drops work. No environment is modified (no unsafe). It only applies
when an X server is reachable (DISPLAY set); otherwise winit's default
is kept, so a missing XWayland can't stop the app from starting. Other
platforms ignore it.

The Wayland drop notice now points at the preference instead of the
environment variable (all catalogs updated).

Tests: the startup reader (x11, auto, unknown value, wrong type, broken
JSON, missing file), the notice text, prefs_usage, label translations.
Verified on KDE Plasma 6 Wayland: with x11 the window is an X11 window
(XWayland) and dropping a NEF from Dolphin opens it.
2026-10-09 16:06:12 +07:00
Claude 165226b390 fmt: import order in paint_mouse.rs (#1629)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 16:06:07 +07:00
Adam Gincel 35c323c9de feat(ui): keep a brush per painting tool, as in Photoshop (#218)
The brush was one global session setting (only its smoothing was per tool), so
switching tools carried the size, hardness, mode, opacity, flow and dynamics
over. sync_tool_smoothing becomes sync_tool_brush: it saves the session brush
for the outgoing tool and loads the new tool's on every switch. UiState's
smoothing_tool/tool_smoothing become brush_tool/tool_brushes (serde default, so
saved UI state still loads), and the session's own fields (colours, erase flag,
stroke seed) are never carried across a swap.

Called from the frame start, the options bar before the brush chip, both canvas
stroke paths, ui.set {tool} (so tool + brushSize in one call sets the new tool's
size) and select_tool_preset. A tool seen for the first time inherits the brush
in hand at Photoshop's 10 % smoothing.

Tests: each_tool_keeps_its_own_brush, the renamed per-tool smoothing test,
per-tool coverage in the ui.set brushSize test, and pencil_tests switching tools
before setting the smoothing as the options bar does.
2026-10-09 16:06:07 +07:00
McClabe 9b7349d0e5
Keep legacy PSD text origins visible when editing (#1560)
* fix: keep legacy PSD text origins visible when editing

* fix: normalize legacy TySh origins on import
2026-10-09 16:06:03 +07:00
Brennan Stehling 22ba13b47b
macOS: quit with terminate:, not by closing the window (#1575, #1458) (#1624)
Since the native menu bar (#894), Quit runs file.exit, which closes the window through eframe
while AppKit's run loop is still going. AppKit's Touch Bar finder then removes a KVO observer from
the already-deallocated view in the next display cycle (`-[_NSTouchBarFinderObservation
invalidate]`): an uncaught exception that aborts on macOS 27 (#1458) and leaves a windowless app
on macOS 26 (#1575). The window's close button took the same path before #894; ⌘Q used winit's
default menu, which calls terminate:.

Every way of leaving (Quit, ⌘Q, the close button, the OS quit event, app.quit) arrives at
guard_window_close as a close request. Once leaving is settled (nothing unsaved, or the prompt
answered) it now runs Services::quit when the platform has one, keeping the window open for it.
On macOS that is -[NSApplication terminate:], scheduled on the main queue so winit is out of its
event handler and eframe's exiting hook still saves the window layout.

Reproduced in a macOS 26.6.2 VM with XCUITest: on main, Quit, Quit with a document, ⌘Q and the
close button abort with the #1458 stack; with this change all of them exit, write no crash report
and save ui.ron.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:05:58 +07:00
Unemars a273d3b27d
Layers panel: ⌥-click on the line between layers, measured against Photoshop (#1642)
#1066 added the gesture; this makes it behave as Photoshop 25.4 does (measured by sweeping
⌥-hover and ⌥-click a pixel at a time across each line, results read back by script):

- the line takes the pointer from 3 px above it to 5 px below it (was ±3);
- a release frees the layer above the line together with the clipped layers stacked above it,
  in one history step, and makes it the active layer (was: that layer alone); a clip keeps the
  active layer, as before;
- clipping and releasing have their own cursors: PhotoCraft's original clip-below (a hooked
  arrow onto the layer below) and clip-release (the same arrow with a small "no" badge),
  replacing Lucide corner-right-down for both.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:05:52 +07:00
Streetblock 576811263e
Fix combined-mask cache invalidation after pixel edits (#1645)
Co-authored-by: GPT-6 (Codex) <noreply@openai.com>
2026-10-09 16:05:48 +07:00
MarekSpodBiedry a5d92c231c fix(xtask): corpus --all --update-manifest refetches and rewrites every manifest (#948) 2026-10-09 10:42:10 +02:00
callmeBalloch d10d782bc4
AF.3: say which sample set the semantics were fitted on
The previous commit put 189 in both places, but the thumbnail fitting
was done on 176 documents; the set grew to 189 afterwards and the
reader opens all of them. State both, so neither number is overclaimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 07:44:22 +00:00
callmeBalloch 6deb1e0934
AF.3: attribute the Affinity corpus, one sample count, run it on --changed
From the review on storytold/photocraft#1607:
- ATTRIBUTION.md gets a row for corpus/affinity: the four upstreams at
  their pinned commits, with authors and licences (CC0-1.0, and MIT by
  Nick Beeuwsaert, Jeremy Cantu and DAIKI).
- The README said 176 sample documents in the provenance and 189 in the
  limits. It now says 189 distinct public documents in both places; the
  reader opens all of them (298 files with duplicates, no failures).
- crates/affinity/ joins CRITICAL, so test-corpus --changed runs the
  corpus when the reader changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 07:41:49 +00:00
callmeBalloch 2b279ba61b
Merge storytold/main into AF.3 native Affinity import
No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:46 +00:00
callmeBalloch a8d7c6ef3f
AF.3: put the Affinity fuzz targets and corpus back in CI
The maintainers approved the new crate, its fuzz targets and the corpus
upstreams in principle (storytold/photocraft#1607), so this undoes the
interim opt-in: ci.yml fuzzes preview and import_preview again, corpus
--all fetches the Affinity set, and its test runs under the normal
corpus feature. The provenance section stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:40 +00:00
shmuelbiller1 ec350d64ae
fix(engine): finish background job bookkeeping on its document (#1572)
* fix(engine): finish background job bookkeeping on its document

* test(engine): cover Fade state after switching tabs during background job

* style(test): format background job regression assertions

* style(engine): follow rustfmt for background-job regression test
2026-10-09 13:24:33 +07:00
shmuelbiller1 83d5c5436d
fix(autosave): report background write failures and retry unchanged revisions (#1577)
* fix(format): expose asynchronous autosave completion and enqueue errors

* feat(ui): accept nonblocking background autosave outcomes

* fix(desktop): pass worker autosave results to shell

* fix(ui): confirm autosaves only after successful writes and retry failures

* test(desktop): retry failed background autosave with unchanged revision

* test(format): report async autosave write failure and successful retry

* style(ui): compact autosave iterator chains under project rustfmt width

* style(format): apply rustfmt for autosave completion iterator

* fix(format): simplify async autosave completion types for Clippy

* test(format): allow slow Windows autosave worker time to finish

* test(desktop): use bounded wall-clock deadlines for background autosave checks

* style(format): match rustfmt for autosave worker signature
2026-10-09 13:24:30 +07:00
eric 829aca9483
Snap the Marquee drag outline to whole pixels (#1587)
- Rectangular and Elliptical Marquee previews use the same pixel rect the release commits, so the ants and size readout match the final selection.
- Extract marquee_preview_px in canvas.rs and use it in draw_drag_preview.
- Add a regression test: a zoomed-in drag between pixel edges previews whole pixels and commits that same rect.

Co-authored-by: hideoneric <hideoneric@users.noreply.github.com>
2026-10-09 13:24:27 +07:00
Dan Hiorns 5aaefe378e
Layers panel: pop-up sliders for Opacity and Fill (#1592)
Photoshop's Layers panel Opacity and Fill fields have a ▾ that opens a
slider. Add widgets::popup_value_field, a value field with that ▾:

- a click opens the slider below the field; a click outside closes it
- pressing the ▾ and dragging moves the value at once and closes the
  slider on release
- the number can still be typed, scrubbed and stepped with the arrows

A drag (on the slider, from the ▾, or scrubbing the number) sends
layer.setProps live with a per-drag coalesce key, so it is one history
step. The ▾ reuses the translated Opacity/Fill labels (no new strings)
and is disabled on the Background layer, like the fields.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:24:24 +07:00
Books 0bb46df638
fix(raw): a DNG with only DefaultCropSize is cropped from the active area's origin (#950) (#1595)
The DNG default crop was applied only when both DefaultCropOrigin and
DefaultCropSize were present, so a file carrying just the size was
developed at the whole active area. Each tag now takes its own default
from DNG 1.7.1.0: the origin (0, 0), the size the whole image. The
existing bounds check is unchanged, so an origin-only crop that no
longer fits still falls back to the active area with a warning, and a
tag with the wrong number of values is still reported as incomplete.

Fixes #950.
2026-10-09 13:24:21 +07:00
McClabe 7c15011410
fix: open Image command dialogs before applying (#1596) 2026-10-09 13:24:17 +07:00
kubuntugamer 967dcfd324
fix(io): a layered TIFF export warns that retained EXIF will be dropped (#1545) (#1597)
A TIFF export with --tiff-layers reported "EXIF not supported; it will be
dropped" even though the file kept the tags. export_layered puts EXIF into
the Photoshop image resources as 1058, which the TIFF writer emits as tag
34377 and the importer reads back, so reopening the file restored the
original tags. ExportResult.warnings is documented as notes about what an
export will lose, so the message was an incorrect loss report for an export
that loses nothing.

The TIFF codec has no EXIF directory, so the second copy handed to it in
meta.exif was never written; it only existed to make the codec warn. That
copy is gone, and with it the false warning. The flat-TIFF path still hands
the codec the tags, still drops them, and still says so.

The test is the issue's own claim checked in both directions: a layered
export reports no EXIF loss AND the Artist tag survives the round trip, so
the missing warning reflects a real absence of loss rather than a silenced
one; a flat export is the control and keeps warning. It fails on the exact
reported message before this change.
2026-10-09 13:24:14 +07:00
Claude f55bc64d50 fmt: rustfmt the #1547 save path and test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:24:07 +07:00
kubuntugamer 950eb1dd28 fix(automation): a pathless PSD or PSB save leaves the document dirty (#1547)
Headless::save recorded the saved revision only for .pcraft, so saving a
document back to its own PSD or PSB wrote the file correctly and still
left session.list reporting dirty: true. Automation clients that use the
flag could not tell a saved document from unsaved work.

The test is now "was this a layered document save" (PSD, PSB or .pcraft)
rather than "was this .pcraft". A flat export (PNG, JPEG, ...) stays a
copy: it leaves dirty and the document's path alone. The desktop app.save
path already recorded the revision for every format, so this only affects
the headless JSON-lines server and the MCP stdio server, which both route
through Headless::save.

Tests cover the reported sequence for both .psd and .psb -- create, reopen
the saved file, edit, then doc.save with no path -- plus a control that a
flat export is a copy. docs/control-protocol.md now states the outcome of
doc.save for the dirty flag and the document path.

Co-authored-by: Space Bunny Free <chasbigham@gmail.com>
2026-10-09 13:24:07 +07:00
buivanvu94 e80a691ecf fix(engine): report invalid index on document.inspect and document.activate (#1557) 2026-10-09 13:24:03 +07:00
buivanvu94 1ddda05394 fix(engine): use grammatical article for adjustment layer error messages (#1549) 2026-10-09 13:24:03 +07:00
BFlat f144ec788d
Merge pull request #1633 from storytold/community/1599-1611-ui-set-rotate
Rotate View tool (#1611 by @mvanhorn) and ui_set describes every field (#1599 by @kubuntugamer), carried
2026-10-09 13:24:00 +07:00
BFlat 4a406a6cf6
Pipette cursor test: Windows reports the precise crosshair with its bitmap (Windows CI red since #1160) (#1626)
* Pipette cursor test: Windows reports the precise crosshair with its bitmap (#1160)

test (windows-latest) fails on main in input_tests::eyedropper_and_alt_sampling_show_a_pipette
("Precise keeps the crosshair": left Crosshair, right None). #1437 expected None on Windows because
the canvas drew the crosshair and hid the OS cursor; since #1160 Windows hands the OS a bitmap
cursor and tool_cursor's end-of-pass hook reports Crosshair underneath it. The test now expects
Crosshair everywhere, plus the bitmap on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* i18n(el): translate "Pick a screen pixel or press Esc to cancel"

The last screen color picker string #1618 missed: xtask's
source_key_set_is_covered_by_complete_catalogs still fails on main for el.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 14ed88e399)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 13:23:57 +07:00
BFlat df0fd003d8
i18n(el): translate "Pick a screen pixel or press Esc to cancel" (#1623)
The last screen color picker string #1618 missed: xtask's
source_key_set_is_covered_by_complete_catalogs still fails on main for el.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 13:23:54 +07:00
Elvis33LE f4d489eb96
Command lookup: index registered and shell command IDs (#1156)
Opening a menu or the command palette looked up command ids by scanning the whole command
list. The registry now keeps its ordered specs and an id-to-index map in one OnceLock (first
match wins for a duplicate id), and is_live caches the shell command ids in a set. A batch of
917 lookups drops from 0.157 ms to 0.013 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:26:44 -04:00
callmeBalloch edb42922c1
AF.3: keep Affinity out of CI's corpus and fuzz jobs; state the clean-room provenance
Review on storytold/photocraft#1607 asked maintainers to decide on new
fuzz targets in ci.yml and four third-party corpus upstreams in
corpus --all. Until they do, neither touches CI: ci.yml is main's again
(the targets stay in crates/affinity/fuzz, run locally), and the
Affinity corpus is fetched only by corpus --affinity and tested with
--features corpus-affinity, so --all and test-corpus are unchanged.
The plan is to move those files into photocraft-corpus.

The crate README now spells out the provenance: no Affinity software,
afread's MIT description re-described without translating code, every
structure confirmed on public files (CRC-32 per entry, self-typed
fields), semantics fitted to Affinity's own thumbnails, and how the
sample documents were licensed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:19:56 +00:00
optimalbytee f5a86628ed
Speed up exact Shape Blur convolution (#1566)
Shape Blur now builds its span kernel once per operation instead of per tile. It folds
premultiplication into the f64 row prefix sums and adds whole output rows at a time, in the
same order as before. Output is bit-identical to the previous implementation, which stays in
the tests as an exact oracle. A 24 MP Circle r25 runs in 0.65 s instead of 1.04 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:19:09 -04:00
Claude 2da89df8dc
i18n(el): drop #1611's screen picker rows that main's #1618 already has
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 05:16:41 +00:00
callmeBalloch ade877f8e9
AF.3: count distinct sample documents in the value-budget note
The census behind the limit counted duplicate copies of the same public
files: there are 189 distinct documents, the largest still decoding 3.3
million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:13:22 +00:00
Elvis33LE 1452c49258
PSD save: stream Lr16/Lr32 layer blocks into the output (#1158)
Saving a 16- or 32-bit PSD built the Lr16/Lr32 layer payload in a temporary buffer and cloned
every global tagged block before copying them into the file. The payload is now written
straight into the output with its length patched afterwards, and the neighbouring blocks are
borrowed. Output is byte-identical: all 735 corpus PSD/PSBs re-save to the same bytes. A 24 MP
layer saves in 66 ms instead of 75 ms at 16-bit and 132 ms instead of 164 ms at 32-bit, and the
temporary 144-288 MB copy is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:13:22 -04:00
callmeBalloch 92207eb7c2
AF.3: the read-only Save test follows the asynchronous file dialog
Main now answers File › Save on an unsaved document with a file dialog
the next frame shows, rather than a blocking cancel. The test checks the
same thing that way: an Affinity document asks for a new file and
nothing is written over the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:07:29 +00:00
Claude 52552d7127
Rotate View with ui_set: describe rotation, drop the duplicate Greek row, regenerate the scorecard
- #1599 tests that ui_set's description names every field ui.set accepts; #1611 adds
  `rotation` to ui.set, so the description now names it.
- #1611's el.tsv row for "Pick screen color" duplicates the one #1618 added on main.
- docs/scorecard.md regenerated with cargo xtask scorecard after merging both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 05:07:07 +00:00
Claude 31cf872333
Merge #1611 (mvanhorn): Rotate View tool
# Conflicts:
#	docs/scorecard.md
2026-10-09 05:06:50 +00:00
Claude 346e8e8775
Merge #1599 (kubuntugamer): MCP ui_set describes every accepted field 2026-10-09 05:06:46 +00:00
Wil 3091a2843c
Actions: edit steps, nested action calls, F-key shortcuts and recorded Fit on Screen (#1093)
Steps can be deleted and dragged into a new order, live while recording. Playing an action
while recording stores one named actions.play call, and nested playback stops on a child
failure, rejects cycles and goes at most 16 levels deep. Right-click an action to give it
F1-F12 (saved in preferences). View > Fit on Screen (Cmd+0), Zoom In/Out and 100% record and
replay. New actions.move command; actions.delete takes a step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:03:35 -04:00
Brandon Thomas 6e558d2fc9
i18n(el): translate the saved New Document preset strings (main red since #1516 + #1562) (#1620)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:57:06 -04:00
Jason Xiang 1bc25d363f
New Document: save named presets (Saved category) (#1516)
Save Preset... in File > New stores width, height, resolution, mode, depth, background (with
its colour) and display units in preferences. They are listed under a Saved category, where a
preset can be selected or deleted. Adds document.presets.save/list/get/delete; get returns
params for file.new, which gains an optional backgroundColor. Bad saved entries are skipped one
at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:54:37 -04:00
Elvis33LE b7e0d3810d
History: count retained tiles incrementally below the byte budget (#1157)
Every edit's History::trim rebuilt a set of every tile in every retained state, even well
under the budget. Retained tile references are now counted as states are recorded, evicted,
undone, redone, purged and cleared, and below the budget trim only looks at the current
document's tiles. Record plus trim on a 24 MP, 150-layer document with 50 states drops from
62 ms to under 2 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:48:31 -04:00
Brandon Thomas 44d7902f08
i18n(el): translate the screen color picker strings (main red since #1232 + #1562) (#1618)
#1232 added four tl! strings while #1562 was in review, so every_tl_literal_is_translated
failed for el once both landed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:45:50 -04:00
BFlat fea4a000d0
full_res_preview test: wait again when the software rasterizer outlasts egui_kittest's 10 s render wait (#1593)
test (windows-latest) fails on main in adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off
with "PollError: The requested Wait timed out before the submission was completed.": egui_kittest
waits at most 10 s for the GPU, and on the Windows runner the adapter is WARP, where the first
full-resolution composite of the 6 MP test document can take longer. The submitted work still
completes, so the pixel read now waits up to three times; the assertions are unchanged.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 11:44:58 +07:00
jason4short fe6e90e676
Dodge and Burn tone each pixel once per stroke (#1041)
Each dab re-applied the tone curve to pixels earlier dabs had already toned, so overlaps and
scrubbing compounded into blotchy results. A stroke now tones each pixel once from its original
colour, mixed in by the stroke's coverage, which builds up as a brush stroke's does, capped at
the set Exposure. Further strokes build on it, as in Photoshop. The originals are kept in dense
64 px cells: a 24 MP, size 500 stroke takes 1.7 s (6.0 s before). The live stroke matches the
commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:43:45 -04:00
Matt Van Horn 4f7bd1f28f
Regenerate the scorecard after the Rotate View tool
Marks TOOL-214-17 done and drops the now-read trackpad rotate preference
from the unread list. The Paint Bucket test mapping carries the view's
camera angle.
2026-10-09 04:40:22 +00:00
Matt Van Horn 690dafd61b
Add the Rotate View tool in the Hand flyout
Turns the canvas camera around its centre without rewriting pixels.
Reset View zeros the angle; Match Rotation copies it across documents.
Trackpad rotate follows Preferences › Enhanced Controls › Rotate View with Trackpad.
2026-10-09 04:40:22 +00:00
callmeBalloch e2ad28838e
AF.3: bound the values a document stream decodes
A value takes about 40 bytes in memory however few it took in the file,
and a bool array packs eight in a byte, so a small, highly compressed
doc.dat could declare billions of values and make the import abort on
allocation (review on storytold/vectorcraft#667). The parser now counts
fields and array elements against a 2^24 budget before allocating, and
reserves array storage fallibly, returning Error::Limit either way.
The largest of 333 public documents decodes 3.3 million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:31:42 +00:00
Γιῶργος (George) 1910b9f87c
Add Greek (Ελληνικά) UI localization (#1562)
A complete Greek catalog (el.tsv) registered with the other complete-menu languages: menus,
dialogs, preferences, filters, tool names (Greek with the English name in parentheses) and
font weights, plus Greek Services / Hide Others / Show All in the macOS app menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:31:00 -04:00
Arthur Vinícius 9f5ef88e11
i18n(pt-br): separate View/Preview, Flip/Invert and Light/Thin; clearer Wayland notice (#1292)
pt-br labels that shared one translation with a different command now differ (Exibir,
Espelhar, Leve). The Wayland drag-and-drop notice reads as a full sentence, and "Default
Photoshop Size" fits its preset card. Puppet/Perspective Warp keep Photoshop's "Distorção
de...". An unused row with a garbled source key is dropped from fr, id, pl, pt-br and ru.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:30:55 -04:00
abc2cb 0256eaa4e0
Type Color Picker opens the full Color Picker; screen eyedropper with a pixel loupe (#1232)
The Type options bar and Character panel swatches open the full Color Picker (Text Color),
applying to the selected characters or the whole layer via type.setStyle; Cancel is inert. A
shared "Pick screen color" action in the full dialog and compact popups samples the desktop:
X11/Windows/macOS snapshots with an 11x11 magnifying loupe (4x-24x), the XDG PickColor portal on
Wayland, the EyeDropper API on the web. Captures stay in memory, are bounded at 64 MP, and are
ICC-converted to sRGB where the display publishes a profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:30:50 -04:00
callmeBalloch 57ae2fb1a3
Merge storytold/main into AF.3 native Affinity import
Conflicts: File › Open and batch extension lists (SVG, swatches and
Affinity together); the import dispatcher (upstream's type-layer
preparation with the new result fields); the pinned EXR and Affinity
corpora side by side; README, book and development docs.

Affinity type layers now draw through text_import::prepare, so they get
the same raster bound as other imported type, and SVG import fills the
two new ImportResult fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:26:18 +00:00
Merle 8d95a564e3
Windows: brush tips and crosshairs are native bitmap cursors (#1160)
Brush circles and precise crosshairs go to the OS through egui's CustomCursorImage, cached by
shape and DPI, so they track the pointer without waiting for a canvas frame. A per-pass plugin
clears stale images when the pointer leaves the canvas or the tool changes. Tips past winit's
2048 px limit keep the painted outline with a native crosshair at the hotspot. Other platforms
and immediate viewports keep the painter path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:25:53 -04:00
buivanvu94 873e92dbaa
Delete and Backspace delete the layer while a panel widget has focus (#1565)
A focused non-text widget (a Layers row on Windows) reserved Delete/Backspace as navigation
keys, so the shortcut never fired (#1534). They now reach Delete Layer / Edit > Clear unless
the focused widget uses them itself: the Curves graph claims them while focused
(claim_delete_keys), so Delete still removes its selected point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:20:00 -04:00
Willy Foo 64496178d0 fix(ui): release closed-document canvas and navigator textures 2026-10-09 11:12:52 +07:00
Willy Foo 83d1f3115d perf(algo): specialize photo blur and resample channel loops 2026-10-09 11:12:52 +07:00
Nigel Ihza Firdausy d80455ad25 feat(ui): edit independent rectangle corner radii with narrow-panel controls and history tests 2026-10-09 11:12:49 +07:00
Nigel Ihza Firdausy ce35c1455b fix(pen): reshape the final outgoing handle while preserving incoming geometry and linkage 2026-10-09 11:12:49 +07:00
Claude cff8daf185 automation test: as_chunks for the preview pixel check (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 7406c26bd4)
2026-10-09 11:12:46 +07:00
harrisonoconnorhover 0e0311d245 Reject unsupported document indices in MCP screenshots 2026-10-09 11:12:46 +07:00
Harrison O'Connor-Hoover 79e783bcdf
Record Transform Again as one concrete action step (#1440) 2026-10-09 11:12:38 +07:00
MarekSpodBiedry fb71daf2c6
Control channel: ui.click rejects a count above 256 instead of queueing unbounded input (#982) (#1446) 2026-10-09 11:12:34 +07:00
MarekSpodBiedry eb06c9e105
Opening a file with different horizontal and vertical resolutions says the vertical one is not kept (#1018) (#1445) 2026-10-09 11:12:30 +07:00
MarekSpodBiedry 746f2530e6
Plug-in names reject bidi controls, zero-width spaces and other invisible format characters, and need a visible character (#1011) (#1444) 2026-10-09 11:12:27 +07:00
MarekSpodBiedry 9ce06090b1
Plug-ins: a missing parameter's default is clamped to the declared min..max (#1012) (#1443) 2026-10-09 11:12:24 +07:00
kayrockscreenprinting 7ad869eb32
Canvas: a huge view centre draws the selection outline without overflow (#980) (#1441)
`ui.set` takes any finite centre, and the selection outline quantised the
visible document region with unchecked i32 arithmetic. The float-to-i32
casts saturate, then the padding overflowed: `attempt to multiply with
overflow` for a large positive centre and `attempt to subtract with
overflow` for a large negative one, panicking the canvas frame outside any
dispatch guard. Release builds wrapped to an inverted, empty region.

The visible corners are now clamped to ±2³⁰ before the cast. No document
reaches that far, and the padding (at most 2 · 256 · 64 px when zoomed far
out) can't overflow i32 from there. The fix stays in canvas.rs, so ui.set
(which #820 rewrites) is unchanged.

The test draws a canvas frame with a selection after `ui.set` centres of
±1e30 and (3e9, -3e9), at 100 % and at 1/64 zoom (the largest quantisation
step), and checks that a centre on the selection still traces the outline.
It panicked at the multiply before the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:12:21 +07:00
kayrockscreenprinting e486262d3e
Divide Slice: one division makes at most 10000 parts (#1020) (#1504)
slice.divide clamped each axis to 1..1000 but checked no total, so one
request made up to a million slices, and resolving the slice list
(slices::resolve, used by slice.list and export) is quadratic in the slice
count: about 5e11 rectangle visits at a million.

A division over 10000 parts (100 x 100) is now refused with a bad-params
error before any part is created; the params doc says so. Resolving 10000
slices is about 5e7 visits, and the test lists them in well under a second.

The test refuses 101 x 100, 100 x 101 and 1000 x 1000 with the slices and
history unchanged, then divides 100 x 100 into 10000 parts and lists them.
Before the fix 101 x 100 was accepted (10100 parts).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:12:18 +07:00
David Mann 62fd1342dd
Font menu keeps its height and focuses its search field (#1369) (#1505)
egui measures a popup once, when it opens, and never lets it grow back,
so filtering the font list left the menu short after the query was
cleared. While searching, the list now keeps the height it measured
unfiltered.

The search field was only focused while the query was empty, so a
reopened menu with a leftover query had no focus; it is now focused on
every open. The ComboBox closed on any click, including on the search
field; it now closes on clicks outside, and picking a font closes it.

Fixes #1369

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:15 +07:00
David Mann 48f78a9e1e
Help › Search: keep the field focused after a click opens Help (#1447, #1438) (#1507)
Both reports were filed against v0.5.0, which predates e63ff519 (#1027):
there the Help menu used egui's CloseOnClick, so any click inside it,
including on the search field, closed it. The egui menu bar is what
Windows (and Linux) show; macOS uses the native menu bar, hence "on
Windows". That close is fixed on main but not yet released.

What remained on main: menus open on the press, and the search field
asks for focus then, but the release a frame later completes a click on
the Help title, outside the field, so egui (SurrenderFocusOn::Clicks)
took the focus away again. After clicking Help, typing went nowhere
until the field was clicked. The field now asks for focus again on that
release, as on a fresh opening.

The new whole-app kittest drives real clicks (press and release in
separate frames) with and without the custom title bar and at 1.25x and
1.5x scale. It checks the field is focused after Help opens, that typing
searches at once, and that clicking the field and typing keeps Help
open. It fails without this change (focus) and with v0.5.0's
CloseOnClick for Help (menu closes).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:09 +07:00
Unemars 6054a8a019
Crop tool: show the pixels past the canvas while a frame is edited, as Photoshop does (#1508)
A crop with Delete Cropped Pixels off keeps what it cuts away (and a moved layer can reach past
the edges), but the Crop tool drew only the canvas, so a frame couldn't be pulled back out over
those pixels by eye. Photoshop 25.4 (observed) grows the view to every layer's bounds and the
frame from the first press on the frame: hidden pixels under the shield, transparency as the
checkerboard, no canvas border or shadow, until the crop is committed or cancelled.

- crop_ui: `CropState::editing`, set by the press (before egui's drag threshold) and cleared when
  a new default frame is made (commit, Esc) or the tool is left; `shows_beyond_canvas`.
- canvas: `draw_beyond_canvas` composites only the ring between the canvas and every layer's
  pixels (`extra_cmds::reveal_all_bounds`, shared with Image > Reveal All), once per document
  state, at most MAX_TEXTURE texels per side (from a downsampled proxy past that), and draws it
  with the checkerboard out to the frame under the canvas, reaching a point under its edge so the
  edge's anti-aliasing has no seam. The drop shadow and the Line border are off meanwhile.
- compose: `render_reduced_rect`, the reduced render of any area of the document plane.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:05 +07:00
David Mann 284724af37
TIFF: alpha after an unspecified extra sample opens transparent (#1349) (#1513)
TIFF 6.0 allows the alpha at any ExtraSamples position, but the decoder
only looked at the first extra sample, and for RGB/CMYK the tiff crate
drops every sample after its colour type. With ExtraSamples [0, 2] the
alpha was silently lost (and gray [0, 2] opened as plain gray).

The alpha is now the first extra sample marked associated (1) or
unassociated (2), wherever it is. When it is not the first extra sample
of an RGB or CMYK image, the crate is asked for every sample (the
photometric is patched to multiband gray, as for WhiteIsZero) and the
colour and alpha samples are picked per chunk; planar files read the
alpha's own plane. Associated alpha in that position is made straight.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:00 +07:00
David Mann a9fa35ab96
PSD: warn that Color Lookup tetrahedral interpolation is saved as trilinear (#1347) (#1515)
Photoshop's clrL descriptor (lookupType, NM, Dthr, profile, LUTFormat,
dataOrder, tableOrder, LUT3DFileData, LUT3DFileName) has no interpolation
setting, so a tetrahedral Color Lookup layer reopens trilinear and renders
differently. PSD save now reports that in its warnings instead of changing
the composite silently. Trilinear lookups save without a warning.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:11:55 +07:00
David Mann 4dcb2e4088
Undo: a paint stroke's undo keeps the painted layer active (#1356) (#1523)
Selecting a layer is not a history step, so each state kept the target it
had when it was created. Undoing a step therefore jumped back to whatever
was selected when the previous step was taken: rasterize a shape with the
Background active, select the rasterized layer, paint, Ctrl+Z, and the
Background became active again.

The state a step leaves behind now targets what was selected just before
the step (Session::edit and compound steps set the current state's target
before recording), as in Photoshop: undo of a stroke or fill keeps the
painted layer, undo of New Layer or Merge goes back to the layers selected
before it.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:11:50 +07:00
Harrison O'Connor-Hoover 672c0775de
Reject control requests that expire before dispatch (#1525) 2026-10-09 11:11:47 +07:00
Harrison O'Connor-Hoover de9cc5f12c
Do not replay bridge requests after an uncertain result (#1527) 2026-10-09 11:11:44 +07:00
David Mann 9b08da8d98
Free Transform: the reference point moves with the box when scaling (#1329) (#1535)
In Photoshop the reference point is attached to the bounding box: scaling
from a corner or edge, skewing, distorting and perspective carry it along
so it keeps its place within the box (the centre stays the centre, a
corner reference point stays on its corner). PhotoCraft left it at its old
canvas position for all of those drags.

After each of these drags the reference point is now mapped through the
box: its position in the old box's unit frame is mapped onto the new box
(carry_pivot). Alt-scaling, which scales about the reference point, and
rotating about it still leave it put; moving and the numeric options bar
already behaved correctly and are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:11:40 +07:00
kubuntugamer 756ee3de87 fix(automation): MCP ui_set omits three fields it accepts (#1548)
The ui_set tool's `fields` parameter description listed 18 of the 21
fields the desktop `ui.set` control method accepts, omitting colorPanel,
gradientBlendMode and gradientClassic, and then ended with "Other fields
are an error". An agent that copies the list out of tools/list is told
three working fields are invalid.

The description now names all three, in the order UI_SET_FIELDS lists
them, with what each takes: colorPanel ({background: bool}) and
gradientClassic (bool) and gradientBlendMode (a blend mode name).

The new test reads UI_SET_FIELDS from the desktop shell's source and
asserts the schema names every entry, so the list cannot drift again --
which is how this omission arrived. It fails on colorPanel before this
change, and also fails if a later field is added to UI_SET_FIELDS without
updating the description. The parse is anchored on the declaration and
checked against its declared array length, because the doc comment
mentions UI_SET_FIELDS first and scraping that reads unrelated names.

Co-authored-by: Space Bunny Free <chasbigham@gmail.com>
2026-10-08 23:00:31 -05:00
BFlat 3e8d54896d
Fix #1531: duplicate artboards beside the original, drag boards with the Move tool (#1584)
- Layer > Duplicate on an artboard stacked the copy exactly on the original, so
  nothing visible happened. The copy now goes right of the board (100 px gap,
  sliding past boards in the way) with its contents, and the canvas grows to
  show it, in one undo step (single and multi-layer duplicate). `inPlace: true`
  keeps the old behaviour; the Move tool's Alt-drag and Alt+Cmd+T use it.
- Move tool Auto-Select ignored artboards: a press on an empty spot of a board
  picked the Background under it, so the drag never moved the board. A click
  there now picks the board (layers on it first, clipped to the board; Group
  mode stops inside the board), and the drag moves the board with its contents.
- Moving a board past the right/bottom edge grows the canvas, as a new board
  does, instead of cutting it off.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:54:15 +07:00
BFlat b693d690e1
docs: MCP tool names and job tools, codec README JPEG metadata and deep EXR (#1552, #1553, #1559) (#1573)
- architecture.md listed dotted MCP tool names (doc.open, command.run...) that the server
  rejects; it now uses the advertised underscore names, adds the missing tools, and points
  out that dotted names belong to the JSON control protocol.
- development.md's MCP tool inventory gains jobs_list and jobs_cancel.
- crates/codecs/README.md: oversized JPEG EXIF/XMP is dropped with a warning (XMP first sheds
  its layered-document properties), not an error; supported deep EXR opens flattened with a
  DeepFlattened warning, and only deep ZIP and mip/rip-map tiles are unsupported.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:23:19 +07:00
Claude 26de59f54c automation test: new fill and adjustment layers' path is a vector path (#1419)
#1539 gives layer.newFillLayer.* and layer.newAdjustmentLayer.* a "path" parameter (the
vector mask), which registry_filesystem_path_params_are_classified took for a file path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:16:12 +07:00
David Mann 99b6aff26b Fill and adjustment layers take the active path as their vector mask (#1419)
In Photoshop, when a path is selected in the Paths panel (the work path
just drawn with the Pen or made from a selection, or a saved path), Layer
> New Fill Layer > Solid Color/Gradient/Pattern and every new adjustment
layer get a vector mask made from that path: a Solid Color fill with a
vector mask is a shape layer, editable as a vector. The path wins over an
active selection; with no path selected the selection becomes a layer
mask (#1250), else there is no mask. PhotoCraft always used the selection
and ignored the path, so Solid Color filled the whole canvas.

Engine: the new fill and adjustment layer commands take an optional
"path" param ("work", a saved path's name or a path object). The shared
helper mask_new_layer turns it into the layer's vector mask and falls back
to the selection mask without it; an unknown name is an error, not a
panic.

UI: run_command passes the path selected in the Paths panel as "path"
(callers can opt out with "path": null). Drawing a work path with the Pen
and Make Work Path select it, as in Photoshop, and clicking the panel's
empty area deselects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:16:12 +07:00
Xus Badia bfc59c784f
macOS DMG: branded window background and icon layout (#1201)
The DMG opens on a 660x400 Finder window with the PhotoCraft artwork, 128 pt icons and the
Applications link beside the app. packaging/macos/dmg/ holds the SVG source, a reproducible
generator, an 87 KB HiDPI palette TIFF (was 1.0 MB) and the .DS_Store layout. The volume is
named "PhotoCraft" (the background alias resolves by volume name); the DMG file name keeps
the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:11:00 -04:00
Filip Płacheciński f0ae12dd22
Self-host the web app with Docker (nginx, community-maintained) (#1334)
A multi-stage Dockerfile builds photocraft-web with Trunk and serves dist/web from an
unprivileged nginx on :8080, with /healthz, precompressed gzip, the wasm MIME type and
immutable hashed assets. Rust and Trunk follow the release workflow, and the
PHOTOCRAFT_BUILD_SHA/DATE build args stamp About. Documented in packaging/web/README.md;
not built in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:10:55 -04:00
BFlat 9c554e2ead
Fix #1544: Image Size refuses a resample past the raster budget; resampling writes bands as it goes (#1569)
image.imageSize accepted 64x48 -> 300000x300000 (90 billion pixels, each side within the
300000 px limit) and started allocating destination-sized f32 bands. The command now
refuses a resampled canvas whose raster alone would pass the decoders' default allocation
budget (8 GiB on 64-bit), with an actionable message, before touching the document;
resolution-only changes (Resample off) are unaffected. The resampler also writes its bands
into the output a group at a time instead of collecting every band first, so the f32 rows
held at once are bounded by the thread count, not the destination size.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:03:08 +07:00
BFlat f82a55f056
Fix #1543: oversized Satin and Bevel & Emboss sizes render instead of panicking (#1563)
A Satin size or a Bevel & Emboss size or softness of 1e308 reached the
box blur unclamped: the kernel radius saturated to ~2^62 taps and the
render panicked with "capacity overflow"; 1e6 ran for minutes. Drop
Shadow survived because its reach is clamped in margin(). The blur width
is now capped at MAX_REACH (and NaN is 1) in one place, box_width, which
both the CPU tent and the GPU's tent_kernel go through, so values from
commands and from files are bounded alike.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 09:47:13 +07:00
Harrison O'Connor-Hoover 47ad748638
Use rotated preview dimensions on the GPU canvas (#1459) 2026-10-09 09:02:10 +07:00
Streetblock 7fd610741b
Fix PNG text metadata decode budget bypass (#1460)
Bound decoded PNG text and XMP by one shared max_alloc budget before
growing output, retaining CRC validation and malformed ancillary handling.
Cover compressed, plain, Unicode and post-IDAT metadata with small
synthetic regression fixtures, and document the budget's scope.

Opt Streetblock into public GitHub profile-name credits at their request.

Co-authored-by: GPT-6 (Codex) <noreply@openai.com>
2026-10-09 09:02:07 +07:00
kayrockscreenprinting fd74419aeb
Crop: an explicit rectangle past 300000 px per side is refused (#960) (#1470)
image.crop took any explicit width and height, so a crop to 100000000 x
100000000 succeeded (the tiles are lazy) and left a canvas that a following
full-canvas flatten, such as image.trim or image.duplicate with mergedOnly,
could only abort on allocating.

An explicit crop now refuses a side over 300000 px with a bad-params error,
before the document changes. That is the limit file.new, Image Size and
Canvas Size already use. Extending the canvas up to it still works.

The test checks 1e8 x 1e8, 300001 x 10 and 10 x 300001 are refused with
the document and history unchanged, and that 300000 x 2 still extends the
canvas. It failed before the fix (the 1e8 crop returned Ok).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:02:04 +07:00
optimalbytee c7dbb37b3c
Speed up wide Motion Blur with adaptive FFT convolution (#1471)
Keep the merged upstream row kernel for shorter streaks and unsupported inputs.
Use bounded overlap-save convolution of its merged bilinear coefficients when
channel pairs, parallelism and memory estimates favor FFT. Preserve alpha cutoff
behavior, cancellation, selections and depth/model-neutral filtering.

Validate 303 algorithm tests in debug and release, plus native checks and full
image comparisons. Repeated current-upstream comparisons show 2.31x at 24 MP
and 4.15x at 1.5 MP for angle 30 / distance 1000 on the measured host.

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
2026-10-09 09:02:00 +07:00
Nigel Ihza Firdausy 0d058f4c29
fix(psd): write Photoshop-compatible Photo Filter adjustment blocks (#1473)
* fix(psd): emit Photo Filter phfl block with required four-byte alignment (#1455)

* test(psd): golden phfl length, padding, legacy compatibility and Photo Filter variants

* test(psd): compare quantized Photo Filter channels with encoding tolerance

* fix(psd): normalize legacy 18-byte Photo Filter blocks on re-save

* test(psd): full exporter/importer roundtrip and repair of legacy invalid phfl blocks

* style(psd): format Photo Filter golden-block regression test

* style(psd): apply rustfmt layout to exporter compatibility tests
2026-10-09 09:01:57 +07:00
Unemars d6aeb970ee
Camera RAW: a measured Nikon D4 profile (image area, white level, colour matrix, tone curve) (#1475)
NEF carries no colour description, so D4 frames developed with the camera channels taken as
sRGB primaries and no tone curve (dark, pale), the sensor's optical-black columns showed as a
purple stripe on the right, and clipped highlights turned pink (green saturates first).

crates/raw/src/cameras.rs holds camera profiles measured from the cameras' own output (no
third-party tables), applied to TIFF/EP raws by make, model and sensor size. Sensor gains
tone_curve (like DNG ProfileToneCurve, per channel in linear ProPhoto), folded into the gamma
LUT; other cameras develop exactly as before.

Nikon D4, from 148 uncompressed NEFs of one body (ISO 100–10000):
- image area 4928x3280 at (8, 6): where the camera's own full-size JPEG sits, found by aligning
  the two (2 columns of optical black on the left, 50 + 8 on the right);
- black 0 (the data is black-subtracted in camera: the darkest image pixels reach 0);
- white 15520: green saturates at 15546–15672, red and blue at 15687–16383, so one level just
  below green makes clipped highlights white without moving the white balance;
- ForwardMatrix (white-balanced camera → XYZ D50) and tone curve fitted to the embedded JPEGs
  (Picture Control Standard, Active D-Lighting off; 52 frames, smooth areas). On 15 held-out
  frames PhotoCraft's development is 7.0/255 per channel from the camera's JPEG on average,
  against 34 before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:54 +07:00
kayrockscreenprinting b2003a4329
.pcraft: a tile index outside the i32 pixel range is a corrupt file (#938) (#1483)
The loader accepted a manifest tile at any i32 index. TileCoord::rect
multiplies the index by the tile size, so an index past ±2²³ loaded fine
and then panicked ("attempt to multiply with overflow", geom/src/lib.rs)
wherever the layer's bounds were asked for: the status bar, Properties,
edit.transform, the compositor. Release builds wrapped to a rectangle
unrelated to the tile.

Loading now refuses a tile whose rectangle doesn't fit in i32 on both edges
(indices -8388608 ..= 8388606) with FormatError::Corrupt, as it refuses a
duplicate tile. In-app editing can't produce such an index; only a damaged
or crafted manifest can.

The test moves a raster layer's tile to just past each end of the range and
to i32::MAX / i32::MIN (refused), and to the extreme valid indices (loaded,
bounds computed). Before the fix the out-of-range bundles loaded, and asking
the layer for its content bounds panicked at the multiply.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:51 +07:00
David Mann 17be581390
Liquify: Alt reverses Twirl and swaps Pucker/Bloat, as in Photoshop (#1442) (#1484)
Holding Alt (Option) at pointer-down draws the stroke with the selected
brush's opposite: Twirl Clockwise twirls counterclockwise (and back),
Pucker bloats and Bloat puckers. The selected tool is left unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:48 +07:00
Jeremy 3955bf4f19
Canvas: checker moves with the image; pixel grid from 500%, over pixels only (#1491)
The GPU canvas anchored the checker to the top-left of the canvas viewport,
while the pixel grid sits on document pixel edges, so panning or zooming
slid the checker under the image and out of step with the grid. The checker
is now measured from the document origin; its 8 screen pixel cell is unchanged.

The pixel grid appeared from 800% (1200% on the CPU fallback), was faint and
was drawn across empty pixels. It now shows above 500%, lightens a dark pixel
by a quarter, and is skipped where the pixel is transparent (GPU canvas).

New offscreen render tests in tests/checker_canvas.rs cover cell size at nine
zooms, the phase, the grid threshold and strength, and the grid over empty
checker.
2026-10-09 09:01:45 +07:00
kayrockscreenprinting 3f7d8afc78
Puppet Warp: pins that stretch the content too far are refused (#1019) (#1492)
edit.puppetWarp accepted pin destinations that are finite but arbitrarily
far away, and the triangle renderer sizes its tile, bin and lookup
collections from the deformed mesh's bounds: a pin dragged to (2^20, 2^20)
meant 17.6 million tiles (at least 846 MB of bookkeeping) before a pixel
was drawn.

- algo: MAX_WARP_PIXELS (2^30, about 16000 tiles; the Select > Modify
  budget). puppet_warp returns None when the deformed mesh's bounding box
  exceeds it or isn't finite, checked after the solve and before render.
- engine: edit.puppetWarp returns a bad-params error and the edit is
  discarded (layer unchanged, no history step). run_on_layer's and
  deform_selected's warp callbacks now return Result (a LayerFn alias);
  Liquify and Perspective Warp wrap theirs in Ok.
- A stored Puppet Warp smart filter too far to render leaves the smart
  object's pixels as they were.

Tests: algo (far pins refused, a near pin renders) and engine (the issue's
command refused at 8/16/32-bit, with and without a selection, layer and
history unchanged; a rigid move and a 4000 px pin still work; smart object
unchanged).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:42 +07:00
Harrison O'Connor-Hoover 5355221f81
Preserve document depth when splitting vector shapes (#1495) 2026-10-09 09:01:39 +07:00
David Mann 086d78a023
Apply the transform when another layer is selected (#1400) (#1496)
While Free Transform was open (Ctrl+T, or after dragging a Move-tool
transform control), selecting another layer in the Layers panel left the
box and the lifted preview on the old layer. end_if_left only ended the
session when its layer or document went away or the tool changed.

It now also commits the session when the active layer is no longer the
one being transformed, like Photoshop applying the transform on a layer
switch; the next transform (or the Move tool's controls) then starts on
the newly selected layer. Transform Selection is not tied to a layer and
stays open.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:36 +07:00
kayrockscreenprinting 6c4d4e9dc9
Mesh warp of a gray surface writes the old and warped regions separately (#995) (#1500)
warp_mesh_gray (select.transformSelection with a warp mesh) cleared the
source and warped regions as one dense buffer over their union, so a mesh
that moves a selection far away allocated a buffer growing with the square
of the distance: about 37 GiB of floats for a 48 px selection moved
100000 px. b70fbd4 fixed the same thing in the homography path
(transform_cmds::warp_gray); this does the same here: clear the old region,
then write the warped one. Surface is tile-sparse, so a distant region
costs only its own tiles.

Moving a 48 px selection 8192 px, peak RSS of a test binary went from
346 MB to 3 MB (0.41 s to under 0.01 s).

The test warps 8- and 32-bit gray selections by ±100000 px and 300 px and
checks the result is the translated block with the old region cleared.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:33 +07:00
David Mann 2aa1eeadcc
Selection tools: arrow keys nudge the selection outline (#1428) (#1502)
With a marquee, lasso, wand or other selection tool active, the arrow
keys now nudge the selection outline 1 px (Shift: 10 px) through
select.transformSelection, one undo step per press; the pixels stay put.
A floating piece (Cmd-dragged pixels) moves instead. Before, the arrows
did nothing unless the Move tool was active.

Also fixes the Move tool's arrow-key modifiers: egui's consume_key
ignores extra Shift/Alt, so the plain arrow matched first and Shift+arrow
nudged 1 px instead of 10 and Alt+arrow did not duplicate. The most
specific modifiers are now tried first.

Dragging inside the selection already moved the outline, with the move
cursor on hover (#686); a regression test now covers the cursor.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:30 +07:00
BFlat 31cb502caf
Fix #1313: Tab hides all panels, Shift+Tab the panel dock, as in Photoshop (#1388)
* Fix #1313: Tab hides all panels, Shift+Tab the panel dock, as in Photoshop

The right-hand panel dock could not be hidden. Photoshop's keys now work:
Tab hides the Tools panel, the options bar and the dock (Tab again brings
back what it hid), and Shift+Tab hides and shows the dock alone. Both are
shell commands (window.togglePanels, window.toggle.dock), remappable in
Keyboard Shortcuts and runnable over the control channel; a text field or
focused widget keeps Tab for navigation. Translated in every shipped
language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Tab hides all panels: Polish translations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 09:01:02 +07:00
BFlat b58b8df36b
Non-Unicode arguments: translate the desktop notice (follow-up to #1168) (#1359)
* Non-Unicode arguments: translate the notice; keep run's doc comment on run

The "Could not open" notice for a path that is not valid Unicode is
translated in every shipped language, and the CLI's "Run the CLI" doc
comment moves back onto run (run_os has its own).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Non-Unicode arguments: Polish translations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 09:00:44 +07:00
jason4short c04624d119
Live strokes for the History Brush, Healing Brush and the dab tools (#1040)
* Live strokes for the History Brush, Healing Brush and the dab tools

The Clone Stamp's live stroke now covers every brush-based retouching tool:

- History Brush and Healing Brush: LiveClone becomes LiveRetouch, which
  paints from the clone source or the History Brush's history state
  (history_source, shared with the commit). The Healing Brush shows its
  cloned texture while drawing and blends it on release; it also gets the
  source preview under the brush.
- Dodge, Burn, Sponge, Blur, Sharpen and Smudge: their settings are parsed
  once into DabTool, whose per-dab effect both the command (dab_cmd) and the
  new LiveDab use. LiveDab runs each new dab's effect on a working copy of
  the target, in order, and mixes the changed area back at the brush
  opacity with mix_back (split out of apply_dab_stroke), as the commit does.
  Sample All Layers strokes keep the trail (their composite spans the whole
  stroke).

Part of #213 (live previews for brush-based tools).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Format live retouch test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Trail test: use Spot Healing, which still previews a trail

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* LiveDab::push: read new dabs with get(), no slice-index panic path

Rebased onto #1026 as rebased on main; LiveRetouch::begin already reads the
first stroke point with first() (resolved in the rebase) and the Healing
Brush's own source overlay is dropped with clone_overlay.rs in favour of
main's Clone Source preview (#1042).

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 21:58:21 -04:00
BFlat ad857f1c93
Wayland: drop notice offers paste and the XWayland command for this install (#338, #386) (#1073)
* Wayland: drop guidance offers paste and this install's XWayland command (#338, #386)

winit 0.30 (eframe 0.36) delivers no file drops on Wayland. The notice told every
user to run `WAYLAND_DISPLAY= photocraft`, which does nothing for the AppImage
(no `photocraft` on PATH) or the Flatpak (fallback-x11 grants no X socket while
Wayland is available). It now names the command for this install (Flatpak flags,
the quoted $APPIMAGE path, or `photocraft`), omits it without an X server, and
offers pasting a copied image file with Edit › Paste's shortcut, which works on
Wayland since #384. The start screen hint on Wayland says the same.

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

* Wayland drop notice: Polish translations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:57:26 +07:00
jason4short 7892365f93
Clone Stamp: live stroke, strokes start on the press, source preview under the brush (#1026)
* Clone Stamp: show the stroke live while it is drawn

The Clone Stamp painted a translucent grey trail while dragging and only
showed the cloned pixels on release. It now renders live, like the Brush:
`retouch_cmds::LiveClone` feeds the same StrokeRenderer the commit's
coverage uses and, for the tiles each step changes, redraws them from the
pre-stroke pixels with the clone source sampled through the coverage so far
(the same `clone_sample` / `apply_coverage` as `paint.cloneStamp`), so the
preview is what the commit paints. `StrokeRenderer` gains
`take_dirty_rect` / `coverage_in`; `clone_source::resolve` resolves the
mapping without storing the aligned anchor (the commit still stores it).
The canvas's live stroke holds either engine stroke; the Clone Stamp's
params come from one place shared with the commit (`clone_params`).

Part of #213 (live previews for brush-based tools).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Painting strokes start on the press

The Brush, Pencil, Eraser and Clone Stamp started their stroke only once the
pointer moved past egui's click distance, so nothing showed on the press and
the first few pixels of movement arrived at once (a visible jump). A press
on the canvas with a live-stroking tool now starts the stroke immediately:
the first dab shows at once, the drag egui recognises later continues the
same stroke, and the release ends it, whether egui saw a drag, a click (no
second dab) or a long press that never moved. Other tools keep the click
distance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clone Stamp live stroke: show the first dab on the press

A single point makes no dab until the stroke finishes, so the live Clone
Stamp drew nothing on the press and the stamp only appeared on release.
LiveClone now also draws what finishing the stroke would add (the renderer's
tail_preview: a lone first dab, the smoothing catch-up) and redraws it on
every step, as the Brush's live stroke does: the stamp shows on the press.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Press-start strokes use the press event's modifiers

A Shift that arrives with the click (not yet in the frame's modifier state)
was dropped, so a Shift-click no longer connected to the previous stroke.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Box the live brush stroke in EngineStroke (clippy large_enum_variant)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Format live Clone Stamp code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Live Clone Stamp: follow clone_sample's checked signature

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Live Clone Stamp: use main's source preview (#1042), no panic path on the first point

The rebase drops this PR's own source overlay (clone_overlay.rs) in favour of
the Clone Source panel preview that landed in #1042; remove its leftover module
line. LiveClone::begin reads the first stroke point with first() rather than
indexing.

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 21:53:52 -04:00
sunyd168 9cdcfbb1b7
Fix startup panic when UI Font Size is not Small. (#1467)
The font-size plugin was rescaling egui's default fonts on the first frame and overwriting the queued Inter stack, so Name("medium") was unbound on the next pass.

Co-authored-by: sunyd168 <sunyd168@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 08:37:01 +07:00
BFlat cb17db9a81
Numeric fields: arrow-key steps never clamp with a reversed or NaN range (#1450)
f32::clamp panics when the range is reversed or NaN, which a value
field's DragValue range tolerates; the step keeps the value unclamped
then.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 08:36:51 +07:00
MarekSpodBiedry c653e36f1f
Show Extras Options: checkboxes read and set the Show flags they name (#957) (#1320) 2026-10-09 08:36:48 +07:00
MarekSpodBiedry c77e4122ce
Control server: a bad JSON reply carries "id": null like every other unknown-id error (#1006) (#1304) 2026-10-09 08:36:44 +07:00
MarekSpodBiedry 84cca02d68
type.create: an unknown or out-of-range kerning is a bad-params error, as in type.edit (#994) (#1303) 2026-10-09 08:36:41 +07:00
Brandon Thomas 0b5803500c
Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1530)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:24:37 -04:00
Brandon Thomas f5a5a5c9ee
Surface Blur: 4x to 20x faster on 16-bit and 32-bit layers (ranked window sums) (#1529)
The 8-bit sliding histogram (#631) can't serve 16-bit or float layers: a 16-bit threshold spans
thousands of levels and floats have none. The weight `1 - |v - v0| / t` is linear on each side of
the centre value, so the weighted window sum only needs the count, Σv and Σv² of the samples within
the threshold below and above it. A Fenwick tree over the window's distinct values holds those and
slides along the row, so a pixel costs O(radius · log n) instead of O(radius²), for any depth,
with the direct sum's own weight test deciding which samples count. Used from radius 10, where it
overtakes the direct sum; non-finite samples keep the direct sum.

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-08 21:23:19 -04:00
abc2cb 02984af969
Color Range: improve previews, sampling and eyedropper responsiveness (#1377)
* Refine Color Range preview sampling and add selection preview modes

* Avoid formatting ICC profiles on Color Range hover

* Color Range preview honours sampleAllLayers like OK; drop the env-var evidence/benchmark tests; Polish rows

The Sampled Colors preview always judged the composite while select.colorRange
judges the active layer for sampleAllLayers:false, so preview and OK could
disagree. It now reads the active layer's proxy pixels in that case (test:
preview_honours_sample_all_layers_like_ok). The two #[ignore]d tests that
wrote PNGs to COLOR_RANGE_EVIDENCE_DIR and timed a 24 MP hover are removed.
pl.tsv gets rows for the new strings.

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

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:22:03 -04:00
Brandon Thomas 5341bff255
Swatches import/export and the Export As test use the async file dialog (#1528)
#1071 replaced Services::pick_open/pick_save with the async file_dialog
service while #886 (Swatches) and #1394 (Export As scaling test) still used
the old fields, so ui-egui stopped compiling once all three were on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:18:04 -04:00
BFlat ccadeef41a
Fix #1357: every Layer Style effect row shows its checkbox (#1422)
* Fix #1357: every Layer Style effect row shows its checkbox

An effect not on the layer yet was drawn as a greyed label with no
checkbox, so the box only appeared after the effect had been turned on
and off once. Those rows now draw an empty checkbox too, as in Photoshop;
a click on the row still adds the effect turned on. The checkbox drawing
is shared by both kinds of row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Layer Style test: count the effect list's checkboxes, not the pane's

Main's Blending Options pane now draws checkboxes of its own, so the test counts the leftmost column only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 08:08:06 +07:00
BFlat b97a72ac79
File dialogs no longer freeze or crash the app (#673, #574) (#1071)
* File dialogs no longer block the event loop (#673, #574)

Open, Save As, Export As, Place and every other file dialog ran
rfd's synchronous dialog inside winit's event handler. On macOS that
runs AppKit's modal loop there, and an event arriving meanwhile
aborted the app (SIGABRT, panic_cannot_unwind); on Linux the frozen
event loop made compositors offer to kill the app.

The platform shell now shows dialogs through one asynchronous
Services::file_dialog: the request is queued with what to do with the
answer, shown at the end of the frame with the window as its parent
(a sheet on macOS, an owned modal dialog elsewhere) and awaited on a
helper thread; the answer comes back through a channel that wakes the
app, which continues the action on a later frame. One dialog at a
time. The unsaved-changes prompt waits behind Save's dialog and closes
the document only once it saved. The web build uses the same service.

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

* brush picker: Import ignores the Open dialog's result, like the other callers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:08:03 +07:00
BFlat c1cb455c7a
Fix #1381: selecting type on Wayland no longer deletes it (#1421)
Wayland input methods send an empty preedit (and at times an empty
commit) whenever the caret rectangle moves, as it does while Cmd+A,
Shift-arrows or a drag select text. With nothing being composed,
ime_update took that as "replace the selection with nothing" and deleted
the selected text. An empty preedit or commit now changes nothing unless a
composition is in progress; a real composition still replaces the
selection.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 08:08:01 +07:00
BFlat c84ec1b43a
Document tabs: elide and overflow into a » menu, carrying #1293 by @LordFarquaadtheCreator (#1367)
Co-authored-by: LordFarquaadtheCreator <fahadfaruqi1@gmail.com>
2026-10-09 08:07:57 +07:00
BFlat c4d1c1f8d1
Fix #1229: the Polygonal Lasso tool removes its last vertex on Backspace (follow-up to #1262) (#1338)
* Fix #1229: the Polygonal Lasso tool removes its last vertex on Backspace

Alt-click polygon mode. The Polygonal Lasso tool itself keeps its points
elsewhere (ui.polygon), so there Backspace still reached Edit › Clear and
wiped the layer, the data loss reported in #1229. It now removes the last
vertex there too (Delete as well), a right-click does the same, and
removing the only vertex cancels the polygon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Pipette cursor test: Windows draws the precise crosshair itself

input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 08:07:54 +07:00
Matt Van Horn 619095bfd0
Pattern Stamp tool in the S-group flyout (#922)
* Add Pattern Stamp as a Clone Stamp flyout sibling

Introduce paint.patternStamp to paint the current pattern through a
brush stroke. Aligned strokes keep a document-space tile origin;
unaligned strokes restart at the first point; Impressionist jitters
each dab with a seeded RNG. Empty patterns and strokes larger than
4096² pixels return an error instead of allocating.

* Record Pattern Stamp on the tools scorecard

Allow the aligned stamp helper's argument count under clippy.

* Pattern Stamp: raise the stroke cap to 8192² so a 24 MP diagonal fits

The 4096² bounding-box cap refused a corner-to-corner stroke on a 6016x4000 canvas (~24M px).
Unit test covers that diagonal and a still-rejected 9000² box; the hostile-input test uses a
footprint above the new cap. Scorecard note labels Impressionist as an approximation.

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

* i18n(pl): translate the Pattern Stamp strings

The Polish catalog landed on main after this PR was written; the
translation-coverage tests need every language to carry new UI strings.

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

* Tool::ALL holds 51 tools now that Red Eye and Pattern Stamp both landed

The rebase onto main (with #1084's Red Eye tool) leaves Tool::ALL one entry
longer; bump the array length and the count test.

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

* Flyout test: qualify egui::Vec2 (panels.rs no longer imports Vec2)

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-08 21:07:06 -04:00
Elvis33LE 578bc905a6
i18n: split "Light" — font weights get their own context, Camera Raw keeps the panel term (#1061)
* i18n: split "Light" - font weights get their own context, Camera Raw keeps the panel term

The plain "Light" entry served two unrelated meanings, and every
catalog had resolved the collision in favour of the font weight (de
"Light", ja "ライト", fr "Maigre", ko "가늘게"...), so Camera Raw's
Light section header showed a weight word in most languages.

- The type tool's style dropdown now shows localized labels while the
  raw string stays the engine's fontStyle key (value/label pairs, like
  the anti-alias dropdown), with each word translated in a new
  fontWeight context (Thin...Black, Italic).
- Camera Raw's Light section reads cameraRaw|Light (Licht, Luz,
  Lumière, Свет, 光...); every catalog gains the missing rows, and a
  new i18n test pins both contexts plus their distinction per language.

* i18n(pl): font weights and Camera Raw keep their split contexts in Polish too

* i18n tests: close the Seafoam test before the font-weight test (rebase fixup)

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-08 21:00:58 -04:00
Ian Swope f3fda94c87
Large document: a notice explains a slow CPU fallback redraw (#1015) (#1427)
* Large document: a notice explains a slow CPU fallback redraw (#1015)

A full refresh that falls back to the CPU compositor blocks the canvas for as
long as it runs, with only a log line to say why. The first one per document
that takes over a second now posts a notice: how long it took, which edits
redraw the whole document, and for the GPU memory budget fallback, that
raising Memory usage raises the budget (up to a quarter of RAM).

* Slow-refresh notice test: check the real "Memory usage" casing; add Polish rows

The last assertion looked for "Memory Usage" while the string says
"Memory usage", so it passed vacuously. The budget case now asserts the
hint is present too. pl.tsv gets rows for the new strings.

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-08 20:53:39 -04:00
Brandon Thomas 7a9a2684a1
Toolbar test: qualify egui::Vec2 (main fails to compile ui-egui tests) (#1519)
#1376's test used the module-level Vec2 import that #886 dropped; each
compiled alone, but not together.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:49:21 -04:00
Brandon Thomas ed6f989bc6
Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1518)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:47:09 -04:00
ljkx 5bef14dbdc
Swatches: user swatch model with .aco/.ase import and export (FILE-215-14, part of #215) (#886)
- photocraft-psd: clean-room .aco (v1/v2, UTF-16 names, RGB/HSB/CMYK/Lab/Gray at 16 bits,
  other spaces kept verbatim) and .ase (groups, global/spot/process, RGB/CMYK/LAB/Gray floats,
  unknown models kept) readers and writers; byte-stable round trips, hostile input -> Err.
- engine: presets::swatches (colours in their own model, display/foreground conversion through
  photocraft-cms working spaces, persisted with the prefs, default 40 colours in 4 groups) and
  swatch_cmds (swatches.list/add/delete/rename/move/newGroup/renameGroup/deleteGroup/use/reset/
  import/export) with graceful-failure tests.
- ui: swatches_ui.rs replaces the const grid (groups, tooltips, click = foreground and selected
  type, Alt/Ctrl-click = background, rename/delete/move, panel menu Import/Replace/Export/Reset),
  Color Picker "Add to Swatches", .aco/.ase via File > Open and drag-and-drop; strings translated
  in every complete catalog.
- Scorecard FILE-215-14 -> done.

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 20:43:01 -04:00
harrisonoconnorhover bf8d52ca74 Fix Paint Bucket sampling across visible layers 2026-10-09 07:39:55 +07:00
MarekSpodBiedry 12b014f677 Edit › Toolbar: hidden tools leave the toolbar (#1116) 2026-10-09 07:39:51 +07:00
MarekSpodBiedry 7842e57085 Select › Modify refuses a radius below the dialog's minimum instead of clamping it to 0 (#993)
modify() rejected only a non-finite radius or one above the maximum, then
clamped with r.max(0.0). A negative or zero radius reported success: Border
cleared the selection, Expand and Contract did nothing, and Feather 0 was
accepted although its documented range starts at 0.1.

Each command now checks the range in its params string (Border 1..200,
Smooth/Expand/Contract 1..500, Feather 0.1..1000). A radius outside it,
including NaN and infinity, returns BadParams naming that range, and the
selection is left as it was. The clamp is removed.
2026-10-09 07:39:48 +07:00
kayrockscreenprinting 9af9df8275
PNM: the file, not the header, bounds what a decode reserves (#1110) (#1365)
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before
reading any, against a Limits budget of one or two bytes per sample, so a
19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹
header panics with "capacity overflow"; PBM reserved 2⁶² bytes before
finding the raster missing and aborted. ASCII reservations are now capped
by what the remaining bytes can hold (a PBM digit is at least a byte, a
PGM/PPM sample at least a digit and a separator), and binary PBM checks
the raster length before reserving.

Limits::none() documents that it is still bounded by usize overflow
checks, but the decoder multiplied w × h × channels, n × 2 (16-bit
samples) and the PFM raster length unchecked. A new sample_count checks
the product, and the length checks use checked_mul.

Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors
(P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without
separators, no trailing whitespace) still decode. The first two failed
before the fix: an abort on the 2⁶² reservation, "capacity overflow" for
the P2/P3 headers, and "attempt to multiply with overflow".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:39:45 +07:00
MarekSpodBiedry 6cb36f3ddb
New Document: a size typed in millimetres or another physical unit keeps its digits (#1147) (#1378) 2026-10-09 07:39:42 +07:00
harrisonoconnorhover d1ff8920d5 Test Export As scaling through real PNG exports 2026-10-09 07:39:39 +07:00
ragtux a7587f6270 Export As: a scale that changes only the height is applied (#1353)
export_document scaled the width alone and skipped Image Size when the
rounded width matched the original, so a 1 × 100 document exported at
50% stayed 1 × 100 while the dialog promised 1 × 50. Scale both sides,
send both to image.imageSize, and let the size label use the same
rounding (never below 1 px) so it names the exported size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 07:39:39 +07:00
Gert Pellin - GPE 2f42b1cc80
Camera Raw opens in a window of its own (#1361)
* Camera Raw opens in a window of its own

The Camera Raw dialog (Filter › Camera Raw Filter and the open-time
dialog) was a full-window overlay drawn over the PhotoCraft window,
covering its title bar: it looked like a fullscreen app without window
controls. Adobe Camera Raw is a separate window.

Show it in an egui immediate viewport: an OS window titled "Camera Raw
(name)" / "Camera Raw Filter (layer)", 1360 x 900 by default, resizable
(minimum 900 x 600). The dialog stays modal: the main window is dimmed
and takes no input while it is open. Closing the window is Cancel (for
the open-time dialog that closes the raw being opened). Where the
platform has no extra windows (ctx.embed_viewports(), the web build) it
is drawn over the main window as before, with its painted title strip.

The drawing code is unchanged apart from the title strip, which is not
painted in a window of its own.

* Camera Raw window: scrim theme token, close-is-Cancel test, document the screenshot gap

- theme::Tokens gains `scrim` (the dim over the main window behind a modal
  that takes all input); Camera Raw uses it instead of a hard-coded colour.
- closing_the_camera_raw_window_cancels: the overlay fallback keeps the
  dialog open, a close request on its window cancels without history.
- control-protocol.md: eframe 0.36 cannot screenshot an immediate viewport,
  so ui.screenshot shows the dimmed main window while Camera Raw has its own
  window; agents read it via ui.inspect.cameraRaw and see it offscreen with
  the snapshot example or kittest (which embed viewports).

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-08 20:39:15 -04:00
BFlat 4477cf6bdf
Fix #1380: the web build asks before leaving the page with unsaved work (#1424)
Closing or reloading the browser tab dropped unsaved documents without a
word, while the desktop app asks before closing its window. The web shell
now registers a beforeunload listener that has the browser show its own
"Leave site?" prompt while a document has unsaved changes (the new
PhotocraftApp::has_unsaved_work, refreshed every frame).


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:38:12 +07:00
BFlat 15e4a00991
Selection tools: ⌥ with nothing selected draws a new selection (#1364)
With #1106's engine change, subtract or intersect with no selection
selects nothing. The UI turned ⌥ (and ⇧⌥) into subtract (intersect)
even with nothing selected, so ⌥-dragging a marquee or lasso on a clean
canvas would select nothing; in Photoshop ⌥ then only draws the marquee
from its centre. A modifier-made subtract or intersect is now a new
selection when nothing is selected; an options-bar Subtract or Intersect
is kept, and the cursor badge follows.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:38:09 +07:00
BFlat acb57c169f
Fix #1387: the Type tool's font, style and size controls show in every theme (#1414)
Outside the Pro themes the options bar showed only the hint "Click to add
text" for the Type tools, so Studio, Studio Light and Classic had no way to
pick a font while typing. The Type options bar now shows in every theme,
as the Move tool's Auto-Select does since #1275.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:38:06 +07:00
BFlat 980cf75492
Fix #1354: the macOS menu bar is modal under dialogs, like shortcuts (#1413)
Native menu clicks went straight to menus::invoke, so a menu command ran
beneath an open dialog: rotating the canvas under Image Size left the
dialog's stale size to be applied on OK. While a dialog, the unsaved
changes prompt or Camera Raw is open, the native rows are now disabled and
a click that raced the update is dropped; Zoom In/Out, Fit on Screen and
100% stay live, as their shortcuts do.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:38:03 +07:00
BFlat e2a760f68d
Fix #1371: read large macOS display ids, keep ICC bytes out of the error (#1412)
NSScreenNumber was coerced with `as integer`, so a CGDirectDisplayID above
AppleScript's integer range (2^29) came back as a real, "2.077748985E+9".
parse_reply rejected it, dropped the only display, and the error quoted
the whole raw line, base64 ICC profile included, in the Display profile
notice and Color Settings.

The helper now asks NSNumber for stringValue; the parser also accepts an
exact scientific-notation id; and the error names only the start of the
first field, capped at 80 characters.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:38:00 +07:00
BFlat 7238b4100d
Move tool moves the selected pixels when there is a selection (#578) (#1068)
* Move tool moves the selected pixels when there is a selection (#578)

With a selection, a Move-tool drag (from anywhere) or arrow-key nudge now
floats the active layer's selected pixels (select.float) instead of moving
the whole layer: the hole is transparent, or the background colour on the
Background layer; Alt copies the pixels instead of duplicating the layer;
Shift constrains the drag to 45 degrees; Enter, Deselect or any other
command drops the piece as one "Move" history step. Layers whose pixels
can't float (type, shape, smart object, group, position-locked, hidden, or
several selected layers) and Auto-Select without a selection behave as
before. select.float gains "copy"; a click that moves nothing no longer
leaves a floating piece behind.

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

* Pipette cursor test: Windows draws the precise crosshair itself

input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:37:57 +07:00
Claude 38c97bdc5d Layers panel: test the Blend Mode dropdown's arrow keys with the Move tool active
The open dropdown takes ↑/↓ ahead of the Move tool's nudge, checked in
the full app. The arrow-key step reads the option with get() and uses the
response's own id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 07:37:54 +07:00
Rozaq 4f99e42dcb fix: support arrow keys in dropdowns 2026-10-09 07:37:54 +07:00
BFlat 8208761a7c
Fix #1288: keep edits to a video frame when the playhead moves (#1395)
Paint, fills and filters edit a video layer's displayed raster, but the
frame stack kept the old pixels, so scrubbing or Render Video re-synced the
stale frame over the edit. Write the displayed content back into the
playhead's frame (video_cmds::store) before any navigation, frame edit,
new video layer or render.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:37:51 +07:00
BFlat 88a47dc7c7
Fix #1248: Create and Release Clipping Mask act on every selected layer (#1392)
With several layers selected, Layer › Create Clipping Mask (⌘⌥G) now
clips every selected layer except the lowest, which becomes the base,
and Release Clipping Mask releases them all, as in Photoshop; each is one
history step. A layer param still acts on that layer alone. The command
returns the layers it changed.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 07:37:48 +07:00
Unemars ce1da78d93
Color Balance: Photoshop's slider gestures, Alt+Cancel Reset, Properties Reset and history (#1256)
Measured on Photoshop 25.4 (Image > Adjustments > Color Balance and the adjustment layer's
Properties): a double-click on a slider (knob or track; Windows double-click counting) zeroes it
in the shown tone, whatever modifier is held; clicks and drags ignore Shift/Ctrl/Alt; the
dialog's sliders step 1 per wheel notch (Shift 10), the panel's ignore the wheel. Holding Alt
turns the dialog's Cancel into Reset (every tone 0, Midtones shown, Preserve Luminosity kept,
dialog stays open). In the Properties panel consecutive edits are one history step, and Reset
first returns to the settings the panel found, then to the defaults. Up/Down in the fields are
every value field's since #1326 (1, Shift 10, as Photoshop); the tests check them on Color
Balance, below zero too.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:35:44 -04:00
Shayan Horbank f5c3d37c5a
Add layer and group color labels (#1283) (#1289)
* Add layer and group color labels (#1283)

* i18n(pl): layer colour label rows for the Polish catalog now on main

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-08 20:32:09 -04:00
Ebisuzawa Kurumi 43b4742e3f GPU startup: a failed CPU start no longer pins later starts to CPU
A start that returned an error kept its gpu-starting.json marker so the
next start would try a safer backend. On the CPU path there is no safer
backend: the marker made every later start plan CPU again and fail the
same way (seen under XWayland, where the GL adapter can't present: "no
graphics adapter found"), until the file was deleted by hand.

Keep the marker after an error only when a safer backend exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:26:55 +07:00
Mr. Alonso Corona dde8fcddce
Curves: add black, gray, and white point eyedroppers (#873)
* feat(curves): add black gray and white point eyedroppers

* fix(curves): preserve luminance with gray-point eyedropper

* Curves eyedroppers: drivable by the command's eyedropper param (rule 1)

image.adjustments.curves and layer.newAdjustmentLayer.curves accept
{"eyedropper":{"point":"black|gray|white","at":[x,y]}} (samples the merged composite) or
{"point",…,"color":[r,g,b]}, resolved by the same curves_eyedropper the dialog uses, so agents
and the CLI can set black/gray/white points without ui.pointer. Bad points, positions outside
the image, transparent samples and malformed values are errors. The picker swatches are the
tone each picker maps to (image data, like the Curves gradient bars), drawn as
Color32::from_gray(level) with a token ring.

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

* i18n(pl): translate the Curves eyedropper strings

The Polish catalog landed on main after this PR was written; the
translation-coverage tests need every language to carry new UI strings.

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-08 20:26:33 -04:00
Diego Chagas d2ad238e82
PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1406)
* PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1405)

ag-psd, GIMP and other writers leave a PSD type layer without image data and
let Photoshop re-render it on open. The importer made those empty pixels the
layer's cache, so the canvas showed nothing until the layer was edited, and
the family guessed from the PostScript name ("CC Wild Words" for
CCWildWords-Regular) never got corrected to the installed family
("CCWildWords"), so Missing Fonts replaced an installed font.

- crates/io/src/psd_import.rs: a type layer's record pixels are the cache only
  when there are some.
- crates/io/src/text_import.rs (new, run by import): resolves each run's
  PostScript name with FontDb::resolve_postscript, as the layout does, and
  writes the installed family, weight and italic back into the run; renders
  every type layer that has no cache from its model.
- crates/io/tests/psd_type_bounds.rs: a type layer exported without pixels
  imports rendered, on the engine's own pixels; "JetBrainsMono-Regular" (guess
  "Jet Brains Mono") imports as the family "JetBrains Mono".

Fixes #1405

* PSD import: cap the automatic type render for untrusted sizes

text_import::prepare now renders a type layer without pixels only when its
ink fits MAX_RENDER_PIXELS (64 Mpx); the renderer's own guard is 256 Mpx,
which for an absurd type size or scale in a hostile PSD meant several GB of
f32 accumulation buffers on open. Over the cap the layer keeps empty pixels
until edited, as before. Test: hostile_type_sizes_without_pixels_import_without_a_huge_render
(an ink between the two caps, plus 1e30 pt, a 1e12 scale and f64::MAX).

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

* rustfmt (module order, the hostile-size test)

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

* PSD import: keep empty type pixels when the engine text is blank

cargo xtask test-corpus: rendering every pixel-less type layer regressed the
Photoshop oracle text/path-wave-open.psd (133 -> 132, below the floor).
Photoshop wrote that layer with no pixels because its EngineData text is
blank (the characters sit only in the descriptor's Txt ): it draws nothing,
and the import must not draw it either. New photocraft_text::psd::
engine_text_is_blank; the oracle passes again (133).

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-08 20:22:25 -04:00
SckraT 2619cd2200
fix(ui): wrap New Document preset titles inside their cards (#1401)
Preset card titles were painted with Painter::text, which lays out a single
unwrapped line centred on the card, so translations longer than the 164 px
card (ru "Размер Photoshop по умолчанию", es "Tamaño predeterminado de
Photoshop", ...) spilled past both edges. Lay titles out wrapped to at most
two lines, elide anything longer with a hover tooltip carrying the full name,
and move the size line down to make room.

Test: every preset title in every language fits the card in two lines
without eliding.

Also adds my contributors/people.toml entry.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:24 -04:00
Ca11MeZaMalko b8235e132a
Layers panel: Alt-drag a row to copy the layer there, as in Photoshop (#1216)
* Layers panel: Alt-drag a row to copy the layer there, as in Photoshop

Holding Alt when a dragged Layers panel row is dropped leaves the layer in
place and drops a duplicate above, below or into the target, in one
"Duplicate Layer" undo step; the copy becomes active. A plain drag still
moves the layer. The cursor shows a copy icon while Alt is held.

layer.moveTo takes "copy": true for this and returns the copy's id. The copy
is made by the same helper as layer.duplicate (naming, unlocked Background
copy). A group can be copied beside itself but not into itself.

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

* Alt-drag copy test: expect "A copy 2" for the second copy of A

Main's Duplicate Layer now names copies with Document::copy_name ("A copy", then "A copy 2"),
and layer_copy uses it after the rebase, so the second copy of A in the test is "A copy 2".

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-08 20:16:39 -04:00
jason4short 01752be81a
Lasso: Alt clicks add straight segments, releasing Alt closes the outline (#888)
* Lasso: Alt clicks add straight segments, releasing Alt closes the outline

- With no selection, Alt held at the first press starts straight segments
  (there is nothing to subtract from) instead of latching subtract; with a
  selection, Alt at the first press still subtracts.
- While the button is down the outline follows the pointer, Alt or not: an
  Alt click adds a straight segment, an Alt drag a freehand stretch.
- Releasing Alt while the button is up closes the outline and makes the
  selection (no double-click needed).
- No subtract badge for Alt when nothing is selected.
- The Polygonal Lasso no longer draws handles on its vertices.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Lasso: clicking the last point again closes the outline

A second click on the last straight-segment point (however long after the
first, not a double-click) closes the outline and makes the selection, like
a click on the first point.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Polygonal Lasso: ⌥-drag draws freehand into the polygon

With the Polygonal Lasso, holding ⌥ while the button is down draws a
freehand stretch into the polygon; releasing ⌥ goes back to straight
segments and the polygon stays open. ⌥ at the first click with nothing
selected starts a new selection (nothing to subtract from), and shows no
subtract badge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

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-08 20:12:16 -04:00
CindyGest a7f6c2b71c
I18N: Localize startup notices and welcome text (#1094)
* I18N: Localize startup notices and welcome text

Translate startup guidance when it is drawn so it follows language changes. Add welcome and notice-overflow strings to all language catalogs, with a Spanish/English regression test.

Validation: 697 UI tests, fmt, all-target clippy, layers, wasm,
i18n coverage and a fresh native startup screenshot.

* I18N: the notice test reads the catalogs without changing the shared language

The interface language is process-wide (i18n::CURRENT), and the regression test
for translated startup notices set it to Spanish, which other UI tests running
in parallel draw with. It now looks the strings up with an explicit language
(i18n::tr) instead.

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

* I18N: Polish rows for the startup notice strings

pl.tsv landed on main; the coverage test needs every catalog to translate them.

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-08 20:08:07 -04:00
ljkx b0680fd421
Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)

Blending Options > Advanced Blending now works end to end:

- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
  interior effects as group, blend clipped layers as group, transparency
  shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
  model and written back (in place, or added when not at the defaults);
  the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
  (isolated groups and clipping groups stop a deep knockout), clipped
  layers blended individually, effects over the whole layer when
  Transparency Shapes is off, effects built from an unmasked copy when a
  mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
  five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
  Advanced Blending, Blend If with split sliders), translated.

psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.

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

* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.

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

* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard

#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 20:08:03 -04:00
ljkx 4cb7cf3931
Layered TIFF: the review changes of #672 (keep unknown blocks with their byte order, io behind the engine, fixture attribution) (#835)
* Review: keep unknown blocks with their byte order, io behind the engine, fixtures documented

Addresses the review on #672:

* Unknown layer blocks read from a little-endian TIFF are no longer dropped. Their bytes are
  kept verbatim with the signature `MIB8` (`46B8` for `8B64`) marking the little-endian
  layout (`TaggedBlock::is_foreign_order`), so a save back to TIFF restores them exactly. A
  malformed known block is kept the same way. Only a save to the other byte order has to drop
  such a block, with a warning: a PSD export strips them (`strip_foreign_order_blocks`), and a
  big-endian block of unknown layout still cannot go into a little-endian TIFF.
* The UI no longer depends on `photocraft-io`: the layer predicate is exposed by the engine
  (`file_cmds::tiff_would_write_layers`); io is a dev-dependency again.
* The layered TIFF path ends in the same `encode_image` as every flat export, so export-wide
  metadata policies (such as #733's XMP choice) apply to its composite too.
* Scorecard FILE-215-7 is `partial`: extra alpha channels and a Quick Mask are not written.
* The committed fixtures get an ATTRIBUTION.md row and a README describing their contents and
  how they were generated (psdtags + tifffile); the Python generator script is removed.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* tiff_corpus: ask for layers explicitly (headless default is flat since #825)

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* i18n: drop the duplicate Korean "Ambient" entry that fails bundled_catalogs_are_consistent

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* i18n: drop the duplicate Korean "Warmth" entry as well

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* agent_tasks: tolerate Windows holding the test directory open during teardown

The three tests that remove their directory right after cancelling the client failed on Windows (CI and locally) with os error 32: the spawned server task is still closing its files. The cleanup now retries briefly and otherwise leaves the directory for the next run's tmp() to clear, as it already did at the start.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* preset store: a missing directory is an empty store on Windows too

DirBackend::read passed the OS message through, and Windows words a missing directory as "cannot find the path specified (os error 3)", which missing_file() did not recognise, so attaching a store on a path that does not exist yet (first launch, and two engine unit tests) reported actions.json as skipped. The backend now reports every NotFound as "<name>: not found", the wording the predicate and the memory backend already use. Regression test: opening a store on a nested missing directory warns about nothing and writes nothing.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* Review: keep scripts/layered_tiff_fixtures.py, drop the fixtures README and attribution rows

The four .tif fixtures those described were never committed (binary test data stays out of the repo; the tiff_layers tests read them from the gitignored corpus/layered-tiff/, written by the script that tiff_layers.rs:181 still points to).

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* automation: the Direct Selection path commands edit vector paths, not files

#865 added path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint, whose registry docs mention the vector path they return, so registry_filesystem_path_params_are_classified fails on main (Linux and Windows): they join the list of commands whose "path" is a document path.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* i18n(cs): the lower-case "{n} layer|{n} layers" plural (main red since #829)

xtask's source_key_set_is_covered_by_complete_catalogs fails on main: the key reached every catalog but the Czech one. Same forms as the capitalised twin already in cs.tsv.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* Drop the duplicate path.* entries from the workspace test (main already lists them)

main already has path.moveAnchors/moveHandle/bendSegment/convertPoint in this match; the
duplicates trip unreachable_patterns.

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 19:57:19 -04:00
Matt Van Horn 869ecf3cff
Red Eye tool in the J-group flyout (#1084)
* Add the Red Eye tool to the J-group

Click a red pupil to neutralize the flash reflection. Pupil Size
sets the search window around the click; Darken Amount controls how
far corrected pixels go toward a darker, desaturated pupil.
Catchlights stay white. A miss reports an error instead of a silent
no-op. The kernel only reads a window plus a 5 px mask-filter halo.

* paint.redEye: write back only changed pixels, so CMYK/Lab stay untouched

write_rgba re-encoded every pixel of the click window through the naive
RGBA round trip, which regenerates K in CMYK and shifts Lab values. Now
untouched pixels keep their original channel data; only pixels whose RGB
changed are re-encoded. Adds a CMYK test checking untouched pixels inside
the window stay bit-exact.

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

* algo::redeye: cargo fmt

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

* Polish catalog rows for the Red Eye tool; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage tests need every catalog to translate new UI strings.

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-08 19:57:14 -04:00
Elvis33LE 5ecc860ac1
ui.set validates every field before applying the first one (#820)
* ui.set validates every field before applying the first one

ui.set applied fields as it validated them, so a call like
{"tool": "zoom", "theme": "nope"} changed the tool and then replied
ok:false - the caller is told the call failed and assumes nothing
changed, but the app state moved. Nested objects were worse: a non-object
"panels"/"dockTabs" skipped the merge silently and still replied ok:true,
and an unknown nested key (a typo like "layers2") was inserted and then
dropped by the serde round-trip, also with ok:true - the guarantee of
(selectionMode: "add", brushSize: "big", center: [1]) were silent no-ops
too, and brushSize passed raw floats through (`1e300 as f32` = inf).

All fields are now validated and materialized into locals first; the
apply pass cannot fail, so a rejected call applies none of its fields.
Nested objects reject non-object values and unknown keys (the known set
is the serialized current state, so it tracks the struct automatically),
scalars are type-checked, and brushSize clamps into the Brush Settings
panel's range instead of producing an unusable or infinite tip.

* ui.set: validate-first on rebased main; brushSize keeps the journaled dispatch

Rebased onto main and reworked per review:

- brushSize keeps main's journaled `tools.setBrush` dispatch (#744) with
  its semantics intact ("large"/null/non-numeric = the historical silent
  no-op with ok:true, f64::MAX = the command's error). The size is
  validated in the first pass against MAX_BRUSH_SIZE, so the journaled
  command cannot fail after earlier fields were applied.
- colorPanel (#664) joins the validate-first pass through a new
  whole_object helper (unknown nested keys rejected, value replaces).
- dock gets the same unknown-nested-key check (replacement semantics,
  like main).
- center requires exactly [x, y] again: 3+ elements are an error, not a
  silent skip.
- merged_object (merge semantics, panels/dockTabs) and whole_object
  derive the known-key set from the serialized current state, so new
  struct fields are automatically known.

* CI: retrigger ubuntu (only failing job; its logs expired unretrievable, corpus/windows/macos/wasm32 green and the full suite is green locally)

* rebase: colorPanel, brushPicker and brushPickerView join the validate-first set
2026-10-08 19:41:12 -04:00
Elvis33LE 53985e5f34
Color Lookup: keep the .cube input domain and reject the ones we cannot apply (#1130)
* Color Lookup: keep the .cube input domain and reject the ones we cannot apply

DOMAIN_MIN/DOMAIN_MAX (and LUT_3D_INPUT_RANGE) were parsed and thrown
away, so a log-domain or extended-range look was applied to plain 0..1
inputs: silently wrong colours, exactly what the in-code comment
admitted. The domain now travels with the parsed LutFile (validated:
every component finite and min < max), and colorLookup refuses a
non-default domain with the reason instead of shifting the look -
the same unsupported-over-wrong-output call the codecs make. Honoring
a custom domain end to end means carrying it through ColorLookup
(doc, compose, gpu, PSD round trip) and stays open as the follow-up.

* tests: the colorLookup domain rejection needs a pixel layer active

The outer session of the test still had the adjustment layer active, so
the command was disabled before reaching the domain rejection - the
assertion only passed when the active layer happened to differ. Make it
deterministic with a fresh pixel layer.

* Color Lookup: bake a 1D .cube's input domain into the resample

The 1D branch of parse_cube still ignored DOMAIN_MIN/MAX and
LUT_1D_INPUT_RANGE. Since the 1D table is resampled onto a 0..1 cube anyway,
the domain is now applied there (saturating outside), and a degenerate
domain is rejected like the 3D one.

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

* cargo fmt after rebasing onto main

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-08 19:41:07 -04:00
Elvis33LE 9182f8f76d
undo/redo report the step's damage instead of recompositing the whole canvas (#810)
* undo/redo report the step's damage instead of recompositing the whole canvas

Session::edit, undo and redo all cleared last_damage, so every undo of a
local edit recomposited the entire document instead of the edited area
(scorecard P6: undo a move on 20 MP = 256 ms p50 against a 25 ms budget;
P7 redo 144 ms). Only layer.setProps and the move paths computed damage.

Undo/redo now diff the two documents on either side of the history step:
every layer whose pixel identity changed contributes the union of its
change bounds (where it was, where it landed) to last_damage. The identity
covers everything the compositor reads - blending, placement, masks,
effects, and pixels via their copy-on-write tile pointers (both documents
stay alive for the comparison, so tile addresses cannot be reused under
it). Steps the fingerprint cannot bound still composite everything, as
before: canvas size/format/profile changes, layer-structure changes
(order, additions, removals, kind flips), and video layers - so mode
conversions, flatten/merge and structural undos keep the safe full path
by construction rather than by hoping the edit's target layers were
recorded correctly.

* step_damage: structural comparison, document-field and clipping bail-outs

Rework per review (correctness of the bounded claim):

- Document-level fields the compositor reads are compared up front in
  doc_pixels_equal (size, resolution, mode, depth, profile, channels,
  quick mask, global light, patterns, colour table, duotone); any
  difference composites everything. Undoing Layer Style > Global Light
  or an RGB > Indexed conversion (which keeps the pixel format) no
  longer reports Some(EMPTY) and leaves shadows stale.
- Layer clipping and channel exclusion reach outside the layer's own
  bounds; a difference composites everything, like layer.setProps
  already reports for edits.
- layer_fp's 64-bit hash is gone: layers are compared structurally
  (surfaces by copy-on-write tile pointers, blobs by pointer or bytes,
  parameters by ==), so no hash collision can under-report damage.
- Layer and Document are destructured exhaustively in the two compare
  functions: a new field fails to compile until it is classified as
  compositing (compared) or pixel-irrelevant (explicitly ignored), the
  same sentinel the .pcraft manifest uses.
- Groups no longer hash their children (the walk recurses into them),
  so an edit inside a group damages the edited child's extent, not the
  whole group's.

Tests: undo of Global Light, of image.mode.indexedColor, of a clipped
toggle and of an excluded channel each report None.
2026-10-08 19:41:02 -04:00
Brandon Thomas 9d3447b153
Regenerate docs/scorecard.md and docs/parity.md after the latest community merges (#1489)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:34:20 -04:00
Claude b71991e15a Batch layer.moveTo test: make the target group before B moves into G
layer.new.group inserts above the active layer, which was B inside G, so
Target was created inside G and moving G into it was rightly refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy d3632f361b style: remove extra blank line after deleted helper 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 38257f0209 style: apply rustfmt layout for multi-layer drag handler 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 408975423c test(ui): verify multi-selected group drop payload and single-layer fallback 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy bc2b0a3ca6 test(engine): batch group move, stable stacking, undo/redo, nested selections and atomic rejection 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy aec1aa1980 refactor(engine): share cycle checks through batch move implementation 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy b3b5801a73 feat(ui): drag all selected layers into existing groups with a single drop (#1245) 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 4b14baf4f8 feat(engine): expose batch layer.moveTo through the command registry 2026-10-09 06:20:20 +07:00
Nigel Ihza Firdausy 6fbdbdb396 feat(engine): move multiple selected layers in one atomic layer.moveTo operation (#1245) 2026-10-09 06:20:20 +07:00
才倾梦华V 8492f14620
Keep the active document when closing another tab (#1148) 2026-10-09 06:14:12 +07:00
BFlat 10f3d9efad
Pipette cursor test: Windows draws the precise crosshair itself (#1437)
input_tests::eyedropper_and_alt_sampling_show_a_pipette (#1028) expected
the OS crosshair with Other Cursors = Precise, but on Windows the canvas
draws its own crosshair and hides the OS cursor (visible_crosshair), so
the test failed on every Windows run.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 06:13:47 +07:00
Arthur Vinícius 006e1a3432
i18n: translate the Fill dialog, layer style contours and Image Size "(was …)" (#1291)
* fix(i18n): translate the Fill dialog labels, contour names and Image Size "(was …)"

The Fill dialog painted its row labels and the Contents options without
going through the catalog, the layer-style contour presets had no catalog
entries, and Image Size formatted "(was …)" in English. i18n-coverage only
sees tl! literals, so these stayed English in every language while it
reported 100%.

Wrap the Fill labels and the Image Size template in tl!, add the missing
keys to every catalog, and add a live test that checks each complete
catalog covers the Fill options and CONTOURS and that the dialog draws in
the selected language.

* chore(contributors): add my real name to the About credits

* i18n: Polish rows for the Fill dialog, contour presets and Image Size

pl.tsv landed on main after this PR; the translation-coverage tests need
every catalog to carry the new strings.

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-08 19:10:19 -04:00
Remidesbois 46fb94f813
Type Tool: Ctrl/Cmd temporary transform handles while editing (#1091) (#1202)
* type: transform editable text with temporary Ctrl/Cmd handles

* type: cover temporary transforms, editable files and preview costs

* docs: describe temporary Type transforms and automation

* type: release temporary previews immediately when gestures end

* test: benchmark the active 24 MP Type document
2026-10-08 19:10:13 -04:00
Reynold Hu ef9d075f23
Eyedropper: the comparison ring while sampling (#213) (#1227)
* Eyedropper: the comparison ring while sampling (#213)

The colour under the pointer on the upper arc, the current foreground below,
over a thin dark outline, drawn at the cursor for the Eyedropper and for
Alt-sampling with a painting tool. The Precise-cursor preference keeps the
plain crosshair. Scorecard row flipped to done.

* Eyedropper ring: only while the button is held, theme outline

Photoshop shows the comparison ring only while sampling (button down), not
on hover; hovering now draws the plain crosshair and samples nothing, so
there is no per-frame document.pixel call when idle. The outline uses the
theme's shadow token instead of a hard-coded colour.

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

* Eyedropper cursor: ring first, then main's pipette or Precise crosshair

After rebasing onto main (#1028's pipette cursor), the Eyedropper had two
match arms (the second unreachable) and Precise Other Cursors hid the
comparison ring. One arm now shows the ring while sampling, otherwise the
pipette, or the crosshair when Other Cursors is Precise.

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

* rustfmt canvas.rs

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-08 19:10:08 -04:00
Unemars 3439d327f7
Preferences › Performance › Low Resolution Previews (on by default) (#1247)
Live previews of large documents (filter dialogs, adjustment dialogs that fall back to the
filter preview, zoomed-out adjustment dialogs, an adjustment layer's sliders while they drag)
render on a nearest-neighbour reduced copy: a 16 MP photo previews at every 3rd pixel, drawn
3x blocky at 100 %. Turning the setting off renders them at full resolution.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:10:02 -04:00
Nigel Ihza Firdausy e2956dcf93
feat(ui): auto-scroll Layers panel during layer drag and drop (#1298)
* feat(ui): auto-scroll Layers panel when dragging near viewport edges (#1246)

* fix(ui): import Pos2 for Layers panel drag-edge scrolling

* test(ui): cover drag autoscroll velocity, boundaries, stop conditions and short panels
2026-10-08 19:09:57 -04:00
Brandon Thomas ffe59b4247
Preferences: show Zoom Clicked Point to Center now that the Zoom tool reads it (#1480)
#1241 wired tools.zoomClickedPointToCenter, so prefs_usage's
every_preference_is_read_or_hidden fails while it stays hidden.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:09:47 -04:00
Reynold Hu d57fdf0116
Zoom: the Zoom tool's click honours Zoom Clicked Point to Center (#204) (#1241)
* Zoom: the Zoom tool's click honours Zoom Clicked Point to Center (#204)

Preferences > Tools > Zoom Clicked Point to Center now moves the clicked point
to the centre of the view instead of keeping it under the pointer; scroll-wheel
zoom keeps the point under the pointer either way. The settings audit drops from
57 to 56 unread fields; docs/scorecard.md regenerated.

* Regenerate docs/scorecard.md after rebasing onto main

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-08 18:51:35 -04:00
shmuelbiller1 1656961cc5
Fix/1350 preserve active on save (#1416)
* fix(automation): preserve active document after indexed native save (#1350)

* test(automation): cover indexed save preserving active document (#1350)

* rustfmt inactive_save.rs

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-08 18:51:28 -04:00
Jeremy Fry ec477ca9c4
Rulers: right-click to switch the ruler unit (#1235)
* Rulers: right-click to switch the ruler unit

Right-clicking either ruler (or the corner) opens a context menu listing
every length unit with the active one checked. Picking one runs the same
prefs.set the Units & Rulers preference pane uses, so the change is
immediate and persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rustfmt rulers.rs

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 18:28:57 -04:00
DWAA1660 6f7192d294
Select › Modify refuses a negative radius instead of clamping it (#993) (#1274)
modify() rejected only non-finite radii and radii above the dialog's
maximum, then clamped with r.max(0.0). A negative radius therefore reported
success while leaving the selection alone (Expand, Contract) or clearing it
(Border), against the function's own rule that out-of-range values are
"refused, never clamped silently". Negative radii now get the same
BadParams error, "radius must be a number in 0..max", and the selection is
untouched.

Test: modify_commands also tries -5, -0.5 and f64::MIN for all five
Modify commands.


Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:28:50 -04:00
Unemars 4649946900
Vibrance: Photoshop's Saturation and Vibrance, measured (linear light, 0.288 R + 0.712 G grey) (#1418)
Saturation −100 greyed to the HSL lightness, so a yellow (255, 255, 0) became grey 128 where
Photoshop shows near-white: a yellow logo with Brightness/Contrast +150 and Vibrance › Saturation
−100 clipped to it stayed yellow under Lighter Color. Measured black-box on Photoshop 25.4
(16-bit runs of 7,400-colour charts and HSV sweeps; sRGB, Adobe RGB and ProPhoto documents), both
sliders work in linear light through the document tone curve, Vibrance first, then Saturation:

- Saturation moves each colour from the grey 0.288 R + 0.712 G (blue has no weight, in every
  profile) by 1 + s, clipping per channel: within 0.07/255.
- Negative Vibrance is an HSV change in linear light keeping the hue, in closed form: 0.2/255 on
  average, the deepest shadows (V < 0.05) drift.
- Positive Vibrance: the brightness term is exact; the saturation boost (stronger for dull and
  dark colours, damped on reds and skin) is a fit, 0.4 (+10) to 5 (+100) /255 on average.

CPU (compose::adjust::vibrance_px) and GPU (compose.wgsl) share the constants; the parity test
gains a negative Vibrance and Saturation −100. photocraft-corpus adjustments/rgb8|rgb16/
vibrance.psd (Vibrance +60, Saturation −15): 25.6/255 mean, 89 max → 0.38 mean, 5 max.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:28:42 -04:00
kayrockscreenprinting 64db46f5ab
Puppet Warp: an expansion that empties the mesh is refused, not a panic (#951) (#1410)
With at least one pin, a density or expansion that remeshed to no vertices
panicked in PuppetSolver:🆕 `locate` falls back to vertex 0 when nothing
is closer, and the union-find over an empty mesh indexed it ("index out of
bounds: the len is 0 but the index is 0"). The UI reached it through the
control channel and the options bar's Expansion field (its -50 minimum
empties a 20 px tall bar), and edit.puppetWarp reached it through
puppet_warp, where the dispatch guard caught the panic.

- algo: PuppetSolver::new binds no pins when the mesh has no vertices, and
  puppet_warp returns the source when there are no triangles. With only the
  first change the command erased the layer, since rendering keeps only
  pixels inside the mesh.
- ui-egui: a remesh that leaves no triangles is refused, as `begin` refuses
  a layer without opaque pixels. The previous density, expansion and mesh
  come back. The control channel returns the error; the options bar's
  fields snap back.

Tests in algo, engine and ui-egui follow the issue's fixture and failed with
that panic before the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:28:36 -04:00
kayrockscreenprinting 4f2197d37e
Perspective Warp: replacing the planes mid corner drag ends the drag (#952) (#1398)
A corner drag holds (plane, corner) indices into the plane list. Replacing
the planes through the control channel (`"planes": [...]`) kept the drag, so
the next pointer move or release indexed the new, shorter list and panicked
in set_corner ("index out of bounds: the len is 0 but the index is 0").

Replacing the planes now clears the drag, and set_corner skips indices that
no longer exist instead of indexing blindly.

The regression test draws a plane, presses its corner, replaces the planes
with an empty list, then moves (and, separately, releases) the pointer. It
panicked before the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:28:29 -04:00
ragtux 966511d87f
codecs README: lossy WebP is written by the built-in VP8 encoder (#1352) (#1396)
The capability matrix and the "Lossy WebP" note still said there was no
pure-Rust lossy encoder and that webp_lossless: false was refused. The
VP8 key-frame encoder landed in #880, and the book already documents it;
say the same here: lossless by default, lossy with webp_lossless off and
webp_quality, alpha in ALPH, metadata in VP8X chunks, 16383 px per side.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 18:28:24 -04:00
Jamie Pates 66b560295f
Feature: Add incrementation with up/down arrow keys on numeric fields (#1326)
* Implement number incrementation with up/down arrow keys

* Ensure incrementation functionality works with equations

* Match rounding parity for incrementation
2026-10-08 18:28:18 -04:00
Reynold Hu 5a97ee30f3
Brush tests: seams, hard edge, zero scatter and ABR tip colour (#213) (#1221)
* Brush tests: seams, hard edge, zero scatter and ABR tip colour (#213)

Adds the four TOOL-213-13 regressions: minimum coverage inside a stroke
(spacing 0.1/0.25/0.5), a hardness-1 rim measured against a soft falloff,
dabs staying on the path at zero scatter, and an imported ABR tip painting
the brush colour instead of black. Scorecard row flipped to done.

* Contributors: add Reynold Hu to people.toml

Real name credited, display name synced from the public GitHub profile at the
next contributors.py refresh (every field is opt-in).

* Regenerate docs/scorecard.md after rebase onto main

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-08 18:28:12 -04:00
BFlat da86700693
Right-click Brush Preset picker picks presets like Photoshop (#615, #1031) (#1080)
* Right-click Brush Preset picker: pick presets like Photoshop (#615, #1031)

One picker for the canvas right-click and the options-bar brush chip, drawn
once a frame and kept in UiState (brush_picker, brush_picker_list). Its
preset list is the Brushes panel's (brushes_tab::preset_list): a click picks
and keeps it open, a double-click picks and closes it, drag reorders and
right-click renames or deletes. A gear menu adds New Brush Preset… (named in
the rename bar), Rename/Delete Brush, list or grid view and Import Brushes….
Hardness shows for round tips only; Enter applies a typed size before it
closes the picker. ui.set gains brushPicker and brushPickerView.

Also fixes the Brushes panel's Rename Brush…/Rename Group… context items,
which never opened the rename bar.

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

* Brush Preset picker: Polish translations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:28:06 -04:00
Alex Gorevski ef8610adeb
Fix radial blur sampling density (#515) (#658)
* Fix radial blur sampling density (#515)

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

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

* Bound radial blur quality and parallelize sampling

Add Draft, Good, and Best radial sampling tiers, defaulting to Good, and process sampled blur rows in parallel. Preserve compatibility with serialized filters that omit quality.

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

* i18n(ko): Radial Blur quality choices Good and Best

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
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-08 18:28:01 -04:00
Claude Opus 5.5 f1433f4ab2 Compose: restore the Lab-mix flag instead of clearing it after a tile (#1112)
Rendering set the thread-local LAB_MIX to the document's value around each
tile, then set it to false. A nested rayon job (a gradient fill, say) can
let the same thread render another tile in the middle of an outer one;
clearing the flag there made the rest of the outer Lab tile mix in sRGB.
The issue's reproduction (a 4096 px Lab document with a gradient fill and
two 50 % fills, 60 renders, RAYON_NUM_THREADS=4) showed whole 256 px tiles
differing between identical renders.

psblend::with_lab_mix sets the flag, runs the closure, and restores the
previous value with a drop guard, as with_cmyk_space does for the CMYK
space. The three set/clear pairs in render_tiled_with use it.

The new test sets the flag, renders Lab and RGB documents on the single-
and multi-tile paths, and checks the flag is what it was. It failed before
the fix. The issue's reproduction, run twice each way on macOS with four
threads: before, 6 and 2 of 60 renders had one whole tile wrong (65536 px);
after, 0 of 60 both times.
2026-10-08 18:27:48 -04:00
mnmlyw 3ac39e1ede Content-Aware fill returns the size error for a huge selection extent instead of panicking (#963)
The automatic sampling margin was three quarters of the selection's larger side, computed
as `ext * 3` in u32 (edit.fill) or i32 (edit.contentAwareFill) before the window cap, so a
loaded document with a far-flung selection overflowed and panicked. edit.contentAwareFill
also had no window cap at all and could try to allocate a canvas-sized window.

The margin is now one shared function computed in u64 and saturated to i32, and both
commands check the window against the same 400 M-pixel cap before reading or allocating.
Content-Aware Move uses the shared margin too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:09:47 +07:00
BFlat 652b9726be
Pen tablets: Xwayland for a pen on Wayland, macOS monitor after NSApp (#639, #759) (#1070)
Wayland compositors give a pen only to clients that bind zwp_tablet_v2,
which winit 0.30 doesn't, so on Wayland the pen did nothing in the
window. With a pen attached (BTN_TOOL_PEN in /proc/bus/input/devices),
$DISPLAY set and the X11 libraries installed, the window now opens
through Xwayland, where the X11 reader gets pressure, tilt and the
eraser. PHOTOCRAFT_NATIVE_WAYLAND=1 keeps native Wayland.

macOS: the AppKit event monitor was installed before winit created the
shared NSApplication; it now goes in from eframe's app creation. The
pen's button-up (pressure 0) no longer zeroes the pressure of the
stroke's last moves, which reach the UI in the same frame.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:08:44 +07:00
BFlat d836e3458d
Fix #1272: a reassigned shortcut runs its new command (#1330)
* Fix #1272: a reassigned shortcut runs its new command

Giving F5 to Select › Modify › Feather… warned that Brush Settings would
lose it, but F5 kept toggling Brush Settings. The dialog lists Photoshop's
Window › Brush Settings item (window.panel.brushSettings) and OK cleared
that id, while the key was bound to the shell command it runs
(window.toggle.brushSettings), which kept its default; and the shell
commands came first in the binding table, so they won the key.

The binding table now gives a key the user assigned to that command ahead
of any default that still names it, and an edit to a Window › panel row
applies to the shell command behind it. The same held for F6 (Color) and
F7 (Layers), and for shortcuts set through edit.keyboardShortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Shortcuts: contains_key, not get().is_none() (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 04:36:56 +07:00
Brandon Thomas fa09fcb218
Regenerate docs/scorecard.md (139 preferences after #1016) (#1435)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:28:28 -04:00
Brandon Thomas 6957d7c546
i18n(pl): translate the strings added alongside the Polish catalog (#1426)
The Polish catalog (#1192) landed next to #1016 (System title bar), #1309
(Camera Raw first) and #1293 (document tab overflow), whose new strings it
lacked, so the every-language translation tests fail.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:08:03 -04:00
jerrykimo 17d72cf771
Fix Classic Gradient transparency and open the Gradient Editor (#905)
* Fix Classic Gradient transparency and open its editor

* Translate the minimal Gradient Editor

* Document gradient preset and editor controls

* Group gradient translations to avoid append conflicts

* Split the minimal Gradient Editor out; keep the Classic Gradient transparency fix

The floating editor (preset list + opacity-stop number rows) is not Photoshop's Gradient
Editor, so it goes to a follow-up. What stays: classic drags no longer override the preset
with opaque FG/BG colours, the options-bar swatch shows the selected preset in both modes,
labelled Gradients-panel items, and the classic transparency regression tests. Drops the
ui.set/inspect gradientEditor field and the editor's translations.

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-08 17:05:51 -04:00
Wil 1e1d3dcad4
Fix corrupted reduced canvas previews with separate mip render targets (#892)
* Test separate render targets for canvas mip generation

* Read back canvas mip levels after full and partial uploads

* Gate the mip scratch-texture workaround to DX12

Creating a scratch texture and copying back per mip level on every edit cost about 3.8 ms
p50 for a 128x128 update (the PR's own Metal numbers). The corruption it works around is an
Intel DX12 driver fault, so only DX12 adapters take the scratch path now
(Resources::separate_mip_targets); every other backend renders straight into the level as
before, with main's timings. The readback test runs both paths.

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-08 17:05:45 -04:00
Matt Van Horn b885173da1
Embed PhotoCraft CLI Windows icon and version metadata (#889)
* Embed PhotoCraft CLI Windows icon and version metadata

The CLI crate now sets build to build.rs and takes winresource 0.1 as a
build dependency. On a Windows target the script embeds photocraft.ico
and sets ProductName to PhotoCraft, FileDescription to PhotoCraft
command-line interface, CompanyName to Learning Machines LLC, the shared
copyright line, OriginalFilename to photocraft-cli.exe, and InternalName
to photocraft-cli. Version strings keep the Cargo package version,
including a pre-release tag, and the numeric file and product versions
are major.minor.patch.0. A missing resource compiler prints a cargo
warning and the link continues, while PHOTOCRAFT_REQUIRE_WINRES makes
the script return an error that names rc.exe or windres. package.ps1
runs check-cli-resources.ps1 after the PE subsystem checks and before
signing, including SkipBuild artifacts, and that script checks those
VERSIONINFO fields against the package version and compares the icon
Windows extracts from the CLI with photocraft.ico drawn at 32x32,
without launching the binary. docs/releasing.md records the same CLI
resource contract beside the existing GUI one.

Refs #613

* Drop the untested check-cli-resources.ps1 release gate

It has never run on Windows, and a misfiring 200-line gate in package.ps1 would block
releases. The CLI build.rs icon/VERSIONINFO stays; PHOTOCRAFT_REQUIRE_WINRES=1 in
package.ps1 already makes a missing resource compiler a build error.

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

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:05:38 -04:00
Fahad Faruqi b44133c8b3
Document tabs: elide and overflow into a » menu when they are too many (#1276) (#1293)
The document tab strip laid every tab out at its natural width from the strip's
left edge and never clamped it, so once the tabs were wider than the window the
last ones ran off the strip: only a sliver stayed visible, and picking it
showed the document without bringing its tab into view. The dock *panel* strips
solved the same problem in #151 (elide, then a » menu that keeps the selected
tab on the strip); the document strips never got wired to it.

Both document strips now go through `tab_strip::fit`:

- tabs that don't fit shrink — long titles elide with '…' and show the full
  title on hover — and the ones that still don't fit move into a » menu at the
  end of the strip;
- the active document's tab always stays on the strip, and activating a
  document from the menu brings its tab straight onto it;
- files still opening in the background (#210) share the same index space and
  the same menu;
- tabs that fit keep their exact natural widths and the strip shows no menu,
  as before.

The » button is now a shared `tab_strip::overflow_button`, used by the panel
strips too. "More documents" is translated in every catalog.

Tests: `canvas/tabs_tests.rs` drives the whole app with 14 open documents
(Pro and Studio themes) and asserts the tabs stay inside the strip, the menu
holds the rest, and the active document's tab is the one on the strip.
2026-10-08 17:05:31 -04:00
prekabreki 0423f74b50
Paint Bucket and Gradient keep alpha on a transparency-locked layer (#1104) (#1193)
composite_area already honours lock transparency, but paint_gradient and
bucket_fill(_src) always passed false, and paint.bucket / paint.gradient
dropped the flag target_surface returns. Thread it through, as Edit > Fill
and the brushes already do.

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:05:25 -04:00
prekabreki 2425431c78
layer.delete keeps a document's last layer (#1107) (#1189)
The single-layer path removed the layer with no guard, leaving a document
with no layers and no active layer. It now refuses with the same error as
the multi-select path; the edit is discarded, so nothing changes and no
history step is recorded.

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:05:20 -04:00
wstock 278e294131
channel.merge: reject a repeated source index instead of closing an unrelated document (#1179)
Fixes #934. `channel.merge` closes its sources by current index, sorted and in
reverse, so `documents: [0, 0, 1]` passed validation and then ran `close(1)`,
`close(0)`, `close(0)`; the last call removed whatever document had shifted
into index 0, i.e. one that was never named, with its unsaved edits and
history. Repeated indices are now rejected as bad params before anything is
built or closed, and the first-source lookup no longer indexes the document
list directly.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:05:12 -04:00
wstock 9e6bfdc76c
fix(format): refuse to save a document holding a NaN or infinite float (#1101) (#1172)
serde_json writes a non-finite float as `null`, and the manifest's plain
`f32`/`f64` fields refuse `null` on load. So a document that picked one up
(a PSD `expA` block, a descriptor double) saved and autosaved with a success
report, and every later open failed with `manifest JSON: invalid type:
null, expected f32`. JSON has no spelling for these values, so the save is
refused instead of writing a file that can never be read back.

`finite::check` walks the manifest with a serde `Serializer` that produces
nothing and fails on the first non-finite float, tracking struct fields,
sequence indices, variants and map keys so the error names the value
(`document.layers[2].content.adjustment.exposure`). `PcraftWriter::prepare`
runs it before serialising, so one-shot, incremental, directory and
autosave saves all share it. New `FormatError::NonFinite { path }` with an
actionable message.

Tests: unit (`finite_values_pass_and_each_position_is_named`: each
container kind reports its path) and integration
(`a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load`:
NaN/-inf in an Exposure layer and +inf in `resolution_dpi` are refused with
the field named, through both `save_to_bytes` and `PcraftWriter`, and the
finite document round-trips as before). The integration case was the
issue's repro: on main it saved and failed to load.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:05:04 -04:00
wstock 9d9e512d40
ORF: a file cut short inside IFD0 is malformed, not the compressed variant (#1008) (#1090)
The "is this the undecoded compressed/packed ORF" guard summed the strip
byte counts and compared the sum with width × height × 2. Tiff::tag_uints
returns an empty list when the entry is missing or unreadable, so a file
whose IFD0 entry table is truncated (a partial download, a damaged card)
summed to zero and was reported as "Olympus compressed (or packed) ORF is
not decoded" — a format the user's camera does not produce — while the same
file cut a few bytes earlier or later was correctly reported as malformed.

Missing or unreadable strip byte counts are now RawError::Malformed before
the compression check. Regression test: a synthetic uncompressed ORF cut
right before its StripByteCounts entry is Malformed, and no prefix of the
file reads as the compressed variant.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:04:58 -04:00
Gert Pellin - GPE 75b6931e71
Open camera raws in Camera Raw first (Open / Cancel) (#1309)
* Open camera raws in Camera Raw first (Open / Cancel)

Photoshop opens a raw file in Adobe Camera Raw before it becomes a
document; PhotoCraft developed it with the defaults straight away and
said so in a warning. An interactive open (File › Open, Open Recent,
drag and drop, the command line) of a raw developed from its sensor data
now shows the Camera Raw dialog first, titled "Camera Raw (name)", with
Open and Cancel.

- Open re-develops the raw when Temperature, Tint or Exposure changed:
  as-shot white-balance multipliers times the filter's gains, and the
  develop exposure (photocraft_io::raw::import_raw_tuned). The other
  settings are one filter.cameraRaw step; the document starts
  unmodified. Without an as-shot white balance or without the file's
  bytes, Temperature / Tint stay RGB adjustments as in the filter.
- Cancel closes the document again.
- The "developed with default settings" note is no longer shown when
  the dialog is.
- Preference rawDefaults.openInCameraRaw (default on), the first
  visible Camera Raw Defaults setting. Automation opens (app.open, MCP,
  CLI) never show the dialog.
- "NIKON CORPORATION" + "NIKON D3200" now reads "NIKON D3200" in the
  import note.

Tests: open → set → Open re-develops (brighter, still 16-bit, one
document, unmodified); Cancel closes; unchanged settings add no step;
preference off skips the dialog; a closed document is an error.
Translations for the two new strings in every catalog.

* Camera Raw open: re-develop in the background, keep tab and view

Open re-read and re-developed the whole raw on the UI thread. In the desktop
app it now runs as a job (cameraRaw.redevelop, document locked) like the
open itself; the reply is {job, pending} and the shell finishes when it
lands. The final Camera Raw step goes through app.run (a job too) and the
document is marked unmodified once it lands. Tests and the web still run
inline.

The re-developed document replaces the old one at the same tab position
and path, and its view (zoom, scroll) carries over. New test covers the
background path with documents on both sides.

wb_gains now uses camera_raw::white_balance_gains, shared with the Camera
Raw filter and documented as an observed approximation. 'Developing {name}'
translated in every catalog.

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-08 17:04:51 -04:00
optimalbytee 0ab30f0c9e
Optimize Mosaic by averaging each cell once per tile (#1294)
Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
2026-10-08 17:04:45 -04:00
Elvis33LE 4198da39b0
PSD export parallel layer channels; shared proxy cache; banded CMYK export (#1230)
* CMYK -> sRGB flat export: row bands through the colour transform

cmyk_image_to_srgb materialized three full-size f32 buffers (normalized
input, transform working set, sRGB output) - ~1.4 GB of allocation
churn on a 36 MP export. convert_f32 walks plain strided slices, so the
image now passes through in bands of rows (~32 MB budget): peak
transient memory drops by the document size times two, the output vector
is the only full-size allocation left. Byte-identical output: the
colour test pins the converted pixels and the tagged sRGB profile.

* Banded CMYK export: guard empty images, bounds-checked slices

(BAND_BYTES / (row * ss * 4)) divided by zero for a zero-width image and
clamp(1, 0) panicked for a zero-height one. Guard both, take each band with
checked arithmetic and data().get(), and reuse the output band buffer.
Tests: empty CMYK images convert without panicking; a banded conversion
covers every row.

Note: this PR's single change is the banded CMYK -> sRGB flat export; the
title's parallel PSD layer export and shared proxy cache are not part of it.

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-08 17:04:39 -04:00
prekabreki a32ba211c9
Clear, Cut and Image › Adjustments refuse a pixel-locked layer (#1103) (#1194)
* Clear, Cut and Image > Adjustments refuse a pixel-locked layer (#1103)

clear_area, destructive_adjust and auto_adjust took the layer's surface
directly. They now go through commands::paint_surface, which refuses a
pixel- or fully-locked layer with the same message painting uses.

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

* edit.cut: refuse a pixel-locked layer before filling the clipboard

copy() ran before clear_selected noticed the lock, so a refused Cut still
replaced the clipboard. Check the lock first (check_pixels_unlocked, shared
with paint_surface) and assert the clipboard is unchanged in the test.

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

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:04:33 -04:00
Lime-3010 88568297ab
Show Clone Stamp source preview inside the brush cursor (#1042)
* Show Clone Stamp source preview inside the brush cursor

* Drop the Clone Stamp preview screenshots and doc page

Binary screenshots and a standalone doc page aren't wanted in-tree (review):
remove docs/clone-stamp-preview.md, the two PNGs, their licence file and the
ATTRIBUTION.md row.

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

* Clone preview: pass the command name to clone_sample and propagate its far-source error

clone_sample gained a cmd parameter and returns Result since #1165.

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-08 17:04:27 -04:00
ljkx f41eb4f70d
SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215) (#1305)
* SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215)

crates/io/src/svg.rs: usvg parses (already in the tree through egui_extras); paths become
ShapeLayer knots with the transform chain baked in, quadratics raised to cubics and every
subpath after the first joining the component so the fill rule spans the path as SVG does;
groups keep opacity and blend mode (transform-only and id-only wrapper groups vanish);
text is shaped into outline shapes with the craft fonts plus the system's; gradients become
the layer's gradient fill (stops and angle, with a warning). Clip paths, masks, filters,
pattern paints and images are rasterised by resvg into pixel layers over their bounding box
rather than dropped. Limits: 16384 px canvas, 2000 elements, 24 M px per raster, bounded
recursion; malformed input is IoError::Svg.

smart_cmds::render rasterises an SVG source at the placement's axis scale (1/16 steps,
cached per scale) and divides the placement by it, affine and projective alike; place_bytes
renders that cache up front. svg/svgz join the openable lists; book page; scorecard row done.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* SVG import: never read local files from <image href>

usvg's default image resolver reads any absolute or relative path in an
<image href>, so opening an untrusted SVG could embed local files
(/etc/..., ~/.ssh/...) into the document. Parse with a resolver that
only accepts data: URLs (resources_dir None, string hrefs ignored).
Embedded sub-SVGs already get no <image> resolution from usvg. The font
database (and its system-font scan) is now only loaded when the drawing
may contain text. Regression test: a PNG on disk referenced by absolute
path, file:// URL and relative path is not loaded; a data: URL still is.

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:04:21 -04:00
Claude 24d10c3dd6 Hand tool: translate Fill Screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 03:43:42 +07:00
Rozaq 8e09dcf655 fix: add hand tool scale buttons 2026-10-09 03:43:42 +07:00
TiredIsa 6197270230
Preferences: reset a single shortcut or menu colour override (#988) (#1064) 2026-10-08 16:39:16 -04:00
jason4short e63ff5196b
Help › Search: clicking the search field keeps the menu open (#1027)
Menus close on any click, inside them too (egui's CloseOnClick), so a click
in the Help menu's search field closed the menu. The Help menu now closes
only on clicks outside it; its items (and search results) close it
themselves when chosen.

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-08 16:39:10 -04:00
Matt Van Horn 649417af1d
Relight: size the tile halo from σ, keep large images, speed up the blur (#895)
The neighbourhood radius was a 97 px constant (3σ+1 at σ=32) while softness
can drive σ to 96, so tiles seamed once the short side reached about 1600 px.
Halo is now 3σ+1 plus the finite-difference step, taken from the document's
σ (or the 96 cap when size is unknown).

The kernel used to copy the input unchanged above 8 MP of luma. Large rects
are split into overlapping bands instead, so an 8 MP+ layer is actually relit.

Replace the O(nσ) separable kernel with the three-box Gaussian already used
for other smoothing fields in this crate.
2026-10-08 16:39:05 -04:00
prekabreki e8850ac53a
Filters respect layer locks; render filters and Fill Path refuse a pixel-locked layer (#1102) (#1195)
* Filters refuse a pixel-locked layer (#1102)

run_filter never consulted layer locks. It now refuses a pixel- or
fully-locked layer with the same message painting uses. Channel, Quick
Mask and mask targets are unchanged.

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

* Filters keep alpha under lock transparency; render filters and Fill Path refuse a pixel-locked layer (#1102)

- run_filter puts the layer's alpha back in the tiles a filter changed when
  transparency is locked (the same tile walk as restrict_to_color).
- Flame / Tree / Picture Frame take the surface through paint_surface.
- path.fill refuses a pixel- or fully-locked layer.

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

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:38:59 -04:00
Nigel Ihza Firdausy 7b4e816caf
Keep empty selections empty for subtract and intersect (#1190)
* fix(engine): leave empty selection empty for subtract and intersect

* test(engine): cover empty selection subtract and intersect modes

* test(engine): read selection coverage through Surface API

* test(engine): cover selection subtraction history and format regressions
2026-10-08 16:38:53 -04:00
metzmehard 2b90697d48
Delete with nothing selected deletes the selected layers, of any kind (#1077) (#1087)
Delete / Backspace is Edit › Clear's key, and Clear only works on pixel
layers, so on an adjustment, fill, type or shape layer the key reported
"not available" and nothing happened. As in Photoshop, with no
selection the key now deletes every selected layer (one undo step). A
selection, a targeted layer mask, a single channel or Quick Mask keeps
Clear.

Co-authored-by: metzmehard <310945520+metzmehard@users.noreply.github.com>
Co-authored-by: Liohtml <158847046+Liohtml@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:38:47 -04:00
Minghang Chamling 925bdfe335
fix(text): keep tiny EngineData reals parseable (#979) (#1085) 2026-10-08 16:38:41 -04:00
llduyll10 f985b81300
Layer style: an Outside stroke at Fill below 100 % leaves the interior clear (#906)
Fill 0 % + an Outside stroke is Photoshop's "outline only" look, but on pixel
layers the stroke lay beneath the whole interior at full coverage, relying on
the layer composited on top to hide it. With the fill lowered the interior
showed the stroke colour (a solid disc instead of a ring).

The share beneath an interior pixel is now (1 - a) / (1 - c), with a the
shape's coverage and c the layer's alpha after fill: 1 under an opaque
layer (100 % fill renders exactly as before, so PSD oracles don't move),
1 - a at 0 % fill. CPU compositor and the wgpu shader agree (the layer's
alpha now rides along the coverage seed for pixel layers too).

Tests: compose regression at 8/16/32-bit (0 %, 50 %, 100 % fill), and a GPU
parity case at fill 0 and 0.5 (fails without the shader change).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:38:35 -04:00
Powiadomienie f6c34f63cb
Add Polish translation (#1192)
* Add Polish translation

* Polish: clippy-clean plural rule, plural test, ATTRIBUTION and docs rows

plural_polish used manual range checks (clippy::manual_range_contains) in a
match (); now a plain if/else with ranges. Adds polish_plural_rule, the
pl.tsv ATTRIBUTION row like the other catalogs, and the localization doc row.

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

* pl.tsv: drop three duplicate keys (Wave, High, Low)

bundled_catalogs_are_consistent and i18n-coverage reject duplicate
(context, source) pairs; the first translation of each is kept.

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-08 16:38:28 -04:00
Elvis33LE 077888ea03
PSD plane shuffles in bands; the document histogram computes off the UI thread (#1185)
* PSD plane shuffles in bands; the document histogram computes off the UI thread

interleave/deinterleave walked every pixel of a 20 MP image on one
thread during PSD open and save. Both now split the rows into one band
per core (the file's own par_map/bands helpers) and merge the band
results in order: 20 MP RGB u16 interleave 152 -> 101 ms, deinterleave
461 -> 160 ms (2.9x); f32 rgba 329/730 -> 220/396 ms. Behaviour is
pinned by a parity test against the sequential reference at odd pixel
counts (band boundaries), all depths, with a missing plane in the mix.

The Histogram panel computed its payload inline in the frame every
250 ms while stale - a 41.5 ms UI-thread stall measured on a 24 MP
document, on top of brush work. compute_histograms now runs on a
worker thread; the panel keeps showing the previous histogram with the
existing cached-data warning until the result lands, and a stale
result cannot overwrite a newer revision. Pinned by a panel test that
drives one egui frame and asserts the job is scheduled, delivers off
thread and respects revisions.

* Histogram panel: compute inline when no thread can be spawned; drop the benchmark test

std::thread spawn fails on wasm32 (and can fail natively), which left
hist_job waiting on a disconnected receiver forever. Without a worker the
histogram is now computed inline and delivered through the same channel, a
disconnected job is cleared, and the perf span is measured in the worker.
Removes the dead t0 and the wall-clock benchmark test; adds a dead-worker 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-08 16:38:22 -04:00
wstock ba222715b3
filter.cameraRaw: refuse an area too large to develop instead of overflowing (#1183)
* filter.cameraRaw: refuse an area too large to develop instead of overflowing

Fixes #962. `rgba_region` sized its buffer with `area.width() * area.height()`
in u32, so a legal 65536×65536 canvas (2^32 pixels) overflowed the product and
the command's `run` panicked; dispatch turned that into an internal error. In
release the product wraps to 0 and the first tile read would index past the
empty buffer instead.

The command now checks the area it is about to develop (canvas ∪ the layer's
content) against `MAX_RAW_PIXELS` (400 MP, ≥ 48 bytes per pixel of working
memory) and returns bad-params with the size and the limit. `camera_raw_surface`
carries the same guard so the preview and Smart Filter paths can never overflow
either; the buffer length is computed in u64.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* lens_cmds tests: move the wasm32 cfg back onto lens_correction_batch_over_files

The new Camera Raw test was inserted between the cfg and the batch-over-files
test, which gated the wrong test.

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

---------

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:38:15 -04:00
wstock 64da6bb455
Fit Artboard on Screen: no i32 overflow for a board with large coordinates (#1181)
* Fit Artboard on Screen: no i32 overflow for a board with large coordinates

Fixes #981. `fit_artboard` computed the view centre as `(b.x0 + b.x1) as f32`,
which overflows i32 for a board near ±2^30 on either axis, and for a board
whose far edge `Rect::from_xywh` saturated at i32::MAX. In test and debug
builds the shell view command panicked outside any dispatch guard; in release
the sum wrapped and the view was centred on the wrong point. The midpoint is
now computed in f64 and rounded to the f32 the view stores.

The same repro hit a second overflow on the engine side: `layer.new.artboard`'s
default placement added `GAP` to the rightmost board's far edge, which may
already be i32::MAX. That is now a saturating add (the saturated base then
fails rect validation with an actionable error instead of panicking).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop the artboard_cmds.rs overflow hunk (#1032 fixes it in i64); keep the f64 midpoint

The engine's default-placement overflow lands with #1032, which rejects boards
outside the i32 range instead of saturating. Keep only the Fit Artboard f64
midpoint fix; its test now uses a board that stays in range under #1032.

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

---------

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:38:09 -04:00
才倾梦华V 34225c0f4a
fix(ui): cancel transforms in their originating document (#1170) 2026-10-08 16:38:02 -04:00
wstock 5e13abd67c
A non-Unicode command-line argument is a usage error, not a startup panic (#1108) (#1168)
* fix(apps): a non-Unicode argument is a usage error, not a startup panic (#1108)

Both binaries read `std::env::args()`, which panics on an argument that is
not valid Unicode: `photocraft-cli info $'caf\xe9.png'` died in
`library/std/src/env.rs` before printing anything, and a file manager
handing the desktop app a Latin-1 file name would do the same before any
window opened.

CLI: `photocraft_cli::run_os` takes the raw `OsString`s, and an argument
that does not convert is a usage error naming its position and a lossy
rendering (exit 2, with the usage text), like any other bad argument.
`main` calls it; `run(&[String])` is unchanged for in-process callers.

Desktop: the loop matches on `OsStr::to_str`. Flag values that are paths
(`--control-token-file`, `--automation-*-root`) become `PathBuf`s directly
and so keep working with any name; a non-Unicode `--control-token` is a
control-argument error like a bad `--control` port; non-Unicode file
arguments are collected and reported in a "Could not open" notice once the
app is up, so the launch still succeeds.

Test: `a_non_unicode_argument_is_a_usage_error_not_a_panic` (unix) runs
the binary with a `caf\xe9.png` argument for `info` and `convert` and
asserts exit 2, the message, and no panic output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* photocraft-cli: put run()'s doc line back above run, not run_os

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

---------

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:37:56 -04:00
BFlat 83936bdcee
Fix #1271, fix #1316: Preferences › Interface › System Title Bar (#1344)
* Fix #1271, fix #1316: Preferences › Interface › System Title Bar

Since #986, Windows and Linux draw PhotoCraft's own one-row title bar
with its own window buttons. On tiling window managers those buttons do
nothing, and some Linux desktops look better with their own decorations.
The new System Title Bar preference (off by default) gives the window the
system's title bar and buttons back; it is read when the app starts.
macOS always uses the system's. Translated in every shipped language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

* Scorecard: count the System Title Bar preference

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 03:36:21 +07:00
Streetblock 84b76a8c9c
Preflight pattern allocations and reuse decoded channels (#1242) 2026-10-08 16:19:46 -04:00
Unemars f08cffd4d1
Select › Color Range: Photoshop's Sampled Colors model, Subtract from Sample and the eyedropper on the image (#1239)
* Color Range: Photoshop's Sampled Colors model and the eyedropper on the canvas

Sampled Colors now compares colours in Lab (through the document's profile) the way
Photoshop does, reconstructed from Photoshop 2024's own masks (observation only):
per-axis distance outside the samples' Lab box in 8-bit units, a/b weighted x3, the
largest of the three, and a quadratic B-spline falloff of width fuzziness*256/255.
On 8-bit sRGB images it matches Photoshop to under one level of 255 on average
(the previous per-channel RGB distance was off by about 12). Several samples span
one Lab box, like Photoshop's Minimum/Maximum.

While Color Range is open, the pointer over the free canvas is its eyedropper:
a click picks that pixel, Shift adds to the sample, Alt subtracts (also through
ui.pointer on the control channel).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Color Range: Photoshop's Subtract from Sample, click order, 8-bit Lab grid

Measured through Photoshop's real Color Range dialog (eyedropper clicks):
- Subtract from Sample reshapes the samples' Lab box per axis: inside it the
  nearer edge moves onto the subtracted value; on an edge or within the axis
  width w it moves in to the first of m + k*w/5 beyond it (k <= 5); further out
  nothing changes; an edge never crosses the other. Matches every measured box
  to +-0.05. The click order matters, so select.colorRange takes an optional
  `order` list and the dialog records it.
- The a/b centring shift of the previous change was an artefact of scripted
  samples (Photoshop's descriptor Lab scale); with the eyedropper the sampled
  colour is fully selected at any fuzziness. Removed.
- 8-bit documents compare pixels and samples on the 8-bit Lab grid, where an
  a/b step above the samples' range counts half a unit more and one below half
  a unit less (lab_range_coverage_8bit).
On Photoshop's eyedropper masks of a photo the mean error is ~0.46 levels of
255 at fuzziness 40/80 and ~0.02 at 5/10 (previous change ~0.9).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:19:39 -04:00
Nigel Ihza Firdausy 1a9a39eadf
fix(text): discover CJK system fonts through Flatpak host mounts (#1204) 2026-10-08 16:19:32 -04:00
prekabreki 0d11e76fef
Toolbar: the header chevron switches between one and two columns (#1197) (#1200)
* Toolbar: the header chevron switches between one and two columns (#1197)

The Pro theme's chevron was painted with a hover-only sense, so clicking it
did nothing. It now toggles Panels::toolbar_double (serde default, so older
state still loads); the toolbar is two columns when that is on or when one
column doesn't fit, and the chevron points left while two are shown.

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

* Toolbar: the Pro two-column toolbar fills every row (#1197)

Rows were cut per tool group, so a group with an odd number of tools left
a half-empty row (Move, Eyedropper). Pro draws no group dividers, so its
two columns now flow across groups; the other themes keep their per-group
rows between dividers.

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

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:19:26 -04:00
Shayan Horbank ba96d741fb
Improve Color Picker layout and spacing (#1188) (#1196) 2026-10-08 16:19:19 -04:00
wstock 0d6e58e2f4
fix(io): a PSD note icon near i32::MAX saturates on save instead of overflowing (#939) (#1176)
`write_note` built the icon rectangle as `[y, x, y + 20, x + 16]` with plain
i32 arithmetic on a position that comes straight from the file's `Anno`
block (or from `notes.set`). A stored icon top at or above `i32::MAX - 19`
overflowed on every PSD save, because `export_blocks` regenerates the
comparison block before deciding whether the stored one can be kept
verbatim: a panic in debug (surfaced by `file.saveACopy` as an internal
error, no file written), a wrapped bottom edge in release.

The far edge now uses `saturating_add`, matching the cast that already
saturates the origin, so the written rectangle is always representable and
the note survives a round trip at its stored position. No signature change:
`write_note` stays infallible and unchanged notes still compare byte-exact.

Test: `a_note_icon_near_i32_max_saves_with_representable_geometry` builds
the issue's `Anno` fixture at `i32::MAX - 5` (and a far-left and
`i32::MIN` variant), imports, exports, checks the written icon rectangle
and re-imports; also covers a model position beyond i32 and NaN, and the
ordinary `[42, 28]` control. Panicked at `annotations_map.rs:183` on main.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:19:13 -04:00
DWAA1660 c1f7e5f3b1
Fix shared layer style angles and add radial angle dials (#882) 2026-10-08 16:19:05 -04:00
Gert Pellin - GPE 3d09578322
raw: decode Nikon compressed NEF (lossless + lossy) (#1310)
* raw: decode Nikon compressed NEF (lossless + lossy)

Nikon's default NEF (TIFF compression 34713) opened only as the embedded
8-bit JPEG preview. Decode the Huffman-coded strip: lossless and lossy
type 1 / 2, 12 and 14 bit, with the seeds and curve of maker note 0x0096
in the maker note's byte order. "Lossy after split" files stay
unsupported and keep the preview fallback.

Clean-room: format facts from prose write-ups (Clévy, Claff, ExifTool tag
names, T.81). The fixed Huffman tables are not in the files; they come
from LightCraft's black-box analysis of CC0 raw.pixls.us samples
(storytold/lightcraft#86, verified there on ~45 files from ~35 bodies).
No decoder code is shared: nefc.rs is written against this crate's TIFF
reader.

Also read Nikon BlackLevel (0x003d) in 14-bit units for 12-bit data, as
measured for LightCraft (D750 / D850 / Z 50).

Refs #50.

* raw: Nikon NEF without a BlackLevel tag is black-subtracted in camera

A D3200 NEF's darkest samples are 0-4 (noise around zero): assume 0
without the "black level not recorded" warning when the Nikon maker
note is present but has no 0x003d tag.

* raw: trim Nikon's right-edge padding columns

A D3200 NEF pads every row with 46 columns of 256 (exact in 97.5% of the
rows, lossy-coding noise elsewhere) after its 6034 image columns; they
developed as a pink stripe on the right. Trailing columns (at most 128)
that hold the last column's value in at least 90% of their rows, when
that value is dark, are left out of the active area and crop. A white-
clipped edge is never trimmed.
2026-10-08 16:18:58 -04:00
DWAA1660 347b6c9009
UI Scale Auto keeps the system's fractional scale on 4K (#1072) (#1237)
Auto forced at least 200% on any 4K monitor, overriding a fractional
system scale such as 125% or 150%, so Linux users on 4K got either
100% or 200% and nothing between. Auto now follows whatever scale the
system reports and uses the 200% fallback only when the system reports
no scaling (100%), which is the case #225 was fixing.


Claude-Session: https://claude.ai/code/session_01QcbChgBDVrdhjfsFA5D9nt

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:17:18 +07:00
buivanvu94 a41800c466
fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#1010)
* fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#915, #916, #917, #918, #920, #921, #923, #924, #925, #926)

* Review fixups: drop hunks covered elsewhere, simplify shortcut conflicts

- Drop the brush preview spacing_enabled hunk (#941), the vanishing.rs
  overflow guards (#869) and the artboard_cmds.rs Custom background change
  (conflicts with #1032); ui.window.open already landed in #1133.
- Revert the Measurement Log export change: an empty selection keeps
  exporting the whole log instead of an empty CSV.
- Drop the reach().min(1024) cap: effects still sample past a clamped reach,
  which would give tile seams.
- Shortcut dialog: one pass, newly assigned shortcuts first, taking theirs
  from any holder (overrides included); unchanged overrides only clear
  defaults. More test cases (no-op OK, giving a shortcut back).

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-08 16:09:38 -04:00
Gert Pellin - GPE cbed56b7f1
Desktop: write log records to standard error and a rotating log file (#1143)
* Desktop: write log records to standard error and a rotating log file

The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: GPU startup fallbacks, a lost wgpu device,
the crash guard's panic report. A launch from a desktop menu has no
terminal either, so there was nothing to attach to a bug report.

apps/photocraft/src/logging.rs (no new dependency):
- standard error plus <settings dir>/logs/photocraft.log; each launch
  rotates the previous log to photocraft.1.log and .2.log, so the log of
  a run that crashed survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for photocraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink

The logger is installed first in main (so the panic hook is recorded) and
the file is opened after argument parsing (--version and usage errors
leave no file). Documented in docs/development.md (Logs).

* Contributors: credit switch87 as Gert Pellin

* Desktop: report an unusable log folder through the logger

eprintln! panics when standard error is a broken pipe (a GUI launch on
Windows); log::warn! reaches standard error the same way once the file
sink has given up, and never panics. Same fix as storytold/vectorcraft#465.
2026-10-08 16:09:32 -04:00
Elvis33LE 3ef631619e
Autosave: a thread that fails to start is reported, not a silent no-op (#1124)
Autosaver::new discarded the spawn result with .ok(): under thread or
resource exhaustion the recovery channel pointed at a dead receiver,
every send failed silently and last_result stayed None - crash
recovery was off while looking on. The failure is now recorded where
last_result/flush report it (via the same note helper the worker uses),
so health checks and flush surface it.
2026-10-08 16:09:27 -04:00
yang ba4dc112bb
Artboard placement, edits and export reject coordinates past the 32-bit range instead of overflowing (#931) (#1032)
Default placement added the 100 px gap to the rightmost board's right edge in i32,
editing a board subtracted its old position from the new one in i32, and exporting
negated the board's origin, so a board at an i32 extreme overflowed (a panic in checked
builds, a wrapped position or content offset in release). Rects were also built with
Rect::from_xywh, which clamps the far edge and silently shrank boards past i32::MAX.

Board rects are now built in i64 and checked, so a rect with any edge outside i32 is a
bad-params error naming it; default placement with no room on the right asks for an
explicit x; a move whose offset needs more than 32 bits is rejected unless
moveContents is false; and export reports a board at i32::MIN instead of negating it.
All checks run before the document changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:21 -04:00
BFlat f0208fd798
Move tool: layers overhanging the canvas keep their effect maps; moves copy 2x faster (#761, #128) (#893)
- A layer about the canvas's size keeps its whole effect region even where it overhangs the
  edge, so the region keeps its size while the layer is dragged and the GPU moves its effect
  maps with it instead of rebuilding the shape, distance fields and every effect each frame.
- Surface::translated moves a surface by copying encoded tile bytes tile to tile (no float
  round trip, no full-size intermediate; whole-tile moves share the tiles): 2.3x faster than
  to_interleaved + write_interleaved. Used by layer.translate, Move tool drags and
  algo::resample::translate_surface.
- perf_scenarios: Move tool drag frame rows (styled layer, full-canvas layer with and without
  a drop shadow).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:16 -04:00
Elvis33LE b44cad8747
MCP doc_save: bridge mode refuses headless-only options instead of saving with defaults (#1138)
The bridge branch forwarded only the path to the running app's
app.save, silently dropping quality, format, tiffLayers and index (the
headless branch honours all of them): a client asking for a
quality-50 JPEG got an ok reply and whatever the app's current
settings produce. Those params now fail fast with the reason - naming
headless mode - before anything is written.
2026-10-08 16:09:10 -04:00
yang cd03d49c34
Layer comp offsets out of i32 range are dropped with a warning, not overflowed (#1017) (#1039)
Comp positions were computed with unchecked i32 arithmetic on values from
files: import added a layer's origin to the cmls `Ofst` (and truncated wider
integers with `as i32`), export subtracted the origin from the stored
position, and applying a comp subtracted the current position. Extreme values
panicked with overflow checks on and wrapped otherwise.

Import now keeps offsets at i64 and drops a position whose sum does not fit,
with an import warning; export omits an `Ofst` that does not fit; applying a
comp skips a move that does not fit. Visibility of the same entries is kept.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:06 -04:00
jason4short 26b6aac9ac
Selection tools: ⌘ moves like the Move tool; ⌘⌥ copies (pixels inside the selection, the layer outside) (#896)
* Selection drags: ⌘⌥-drag floats a copy; ⌘-drag with the Polygonal Lasso and Magic Wand

- `select.float {"copy": true}` floats a duplicate of the selected pixels
  and leaves the originals in the layer; ⌘⌥-drag inside the selection uses
  it (⌘-drag still cuts).
- ⌘-drag inside the selection now also floats it with the Polygonal Lasso
  and the Magic Wand (not while a polygon is being drawn); a plain press
  there still places a point / samples, so only ⌘ moves the selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ⌘ / ⌘⌥ with a selection always cut / copy and drag it, never combine selections

With a selection tool and an active selection, a ⌘ or ⌘⌥ press anywhere (not
only inside the ants) floats the selected pixels (cut, or copy with ⌥) and
drags them; ⌥ no longer turns it into a subtract. The cursor shows no
selection-mode badge while ⌘ is held over a selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ⌘ with a selection tool moves the layer outside the selection; ⌘⌥ duplicates it

With a selection tool, ⌘ acts as the Move tool for the drag: inside the
selection it cuts the selected pixels (⌘⌥ copies them), and outside the
selection, or without one, it moves the whole layer; ⌘⌥ duplicates the
layer first and moves the copy. ⌘ never combines selections, so the cursor
shows no add / subtract badge while it is held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ⌘ outside the selection moves the layer, not the selected pixels

Only a ⌘ press inside the selection floats its pixels; outside it the layer
moves (`command_moves_layer`). Drops the duplicate badge check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ⌘⌥-drag on a floating piece drops it and drags a copy

A floating piece (from a ⌘ cut-drag) dragged again with ⌘⌥ is dropped where
it is and a copy of it floats with the pointer, as ⌘⌥ does on any selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

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-08 16:09:00 -04:00
Tanish Manku 964a41dc02
Corpus: pin the deep OpenEXR test images and check them in CI (#881)
The deep EXR decoder (#642) was verified against the OpenEXR project's deep test images, but those
lived in a hand-copied `corpus/exr/` and the test skipped when they were absent, so CI never read
them. `cargo xtask corpus --exr` (and `--all`) now fetches the five `*.deep.exr` files of
openexr v3.5.2 `src/test/bin/test_images`, sha256-verified against `xtask/exr-corpus.sha256`.

`real_deep_files_decode` is behind the `corpus` feature and fails when the corpus is missing. It
checks each file's sample total, largest per-pixel count, cumulative counts and channel lengths,
and that the decoded u32 ID channels hold exactly the IDs listed by the upstream `.txt` sidecars
(an independent oracle), plus the flattened decode. FILE-216-3 moves to partial (EXR read in CI;
TIFF and RAW still are not).
2026-10-08 16:08:56 -04:00
Nigel Ihza Firdausy 05b405aab0
fix(cli): report missing font fallbacks in run, info and convert (#1299)
* fix(cli): report missing font substitutions in run, info, and convert (#1251)

* test(cli): verify missing fonts in run/info/convert and no duplicate or false warnings

* docs(cli): document missing-font fallback warnings in convert, info and run

* style(cli): apply exact rustfmt format for multi-command warning test
2026-10-08 16:08:50 -04:00
Elvis33LE a593b3651c
CPU effect maps: key fill layers by their cached pixels, drop opacity/visible (#1059)
* CPU effect maps: key fill layers by their cached pixels, drop opacity/visible

The layer-effect map cache keyed a Fill layer by its Fill spec alone.
But what shapes the maps is the layer's cached pixels whenever they
still match the fill (what a PSD import stores and Image > Transform /
crop / canvas ops rewrite in place, without touching the spec): after
such a rewrite the CPU compositor kept serving the pre-rewrite maps, so
flattens and the CPU canvas showed the drop shadow of the pre-rewrite
shape while the GPU canvas was correct - until budget eviction or a
purge. The key now hashes the cache surface's tile fingerprint while it
matches the fill, like the GPU's fx::shape_key/group_key already did.

layer.opacity and layer.visible leave the key: the maps depend only on
fill_opacity (opacity is applied when the maps are composited,
visibility only skips the lookup), so an opacity slider drag or a
visibility toggle rebuilt every shadow and glow map over the whole
layer for nothing. Again matching the GPU key.

* Effect-map key: keep child visibility/opacity/blend for groups

A styled group's maps come from its children's composite, so hiding a
child or changing its opacity (or blend, clipping) must change the key;
only the top layer's own visible/opacity stay out. Adds a warm-vs-purged
render test for a group with a drop shadow.

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

* Effect-map test: name the edit tuple type (clippy type_complexity)

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-08 16:08:45 -04:00
jason4short 1dd9a34181
Option flips Dodge/Burn and Blur/Sharpen for a stroke (#1038)
* Option flips Dodge/Burn and Blur/Sharpen for a stroke

Holding Option (Alt) when a stroke starts paints with the opposite tool:
Dodge burns, Burn dodges, Blur sharpens and Sharpen blurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tests for the Option flip of Dodge/Burn and Blur/Sharpen

A unit test for alt_flipped and a stroke test through tool_event with
Option held that checks the journal runs the opposite command.

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:08:40 -04:00
jason4short 2c9ae1fce6
Eyedropper cursor: a pipette instead of the crosshair when sampling (#1028)
* Eyedropper cursor: a pipette instead of the crosshair when sampling

The Eyedropper tool and ⌥-sampling with a painting tool now show the same
pipette pointer as the Color Picker's eyedropper (its tip on the sampled
pixel). Preferences › Cursors › Other Cursors = Precise keeps the crosshair.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep alt_eyedropper's doc comment on alt_eyedropper

pipette_cursor was inserted between the comment and its function.

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:08:33 -04:00
Elvis33LE 96413cff45
Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple (#1021)
* Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple

Grayscale deep files (Y + A + Z) flattened wrong: the tail of the
composite loop wrote a fixed [R, G, B, A] quadruple, so a GrayA image's
alpha slot received an un-premultiplied luminance copy (alpha 1.0 for a
0.5-alpha file) while the real alpha fell past the end of the row. The
values are now written by layout: RGB(A), gray(+A), gray - with the
alpha in the slot it belongs to.

* Deep EXR flatten: no per-pixel Vec allocation

Build a fixed [f32; 4] plus a length instead; this runs once per pixel.

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-08 16:08:27 -04:00
Roaldo Sulejmani 3088577f46
feat(ui): edit the Color panel's hex and RGB readouts (#1016)
* feat(ui): edit the Color panel's hex and RGB readouts

The Color panel's hex readout was a static label. It is now an editable
field: type a colour with or without the leading '#', and it applies as
you type. The R, G and B readouts beside it become value fields too, so a
channel can be set directly.

Both use the dock's themed field surfaces, so they match the rest of the
panel. Partial or invalid input never changes the colour and snaps back
when focus leaves. Editing updates the foreground (or background) and
keeps the panel's hue memory for greys.

Tests cover hex with and without '#', partial input, the RGB fields and
the pro panel's readout.

* rustfmt panels.rs

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-08 16:08:22 -04:00
BFlat 5a2f42c476
CI: build faster and stop running out of disk and cache (#1075)
- Debug/test builds use line-tables-only debug info: the workspace test
  build is 1.9x faster (509 s -> 271 s, measured on Windows) and its
  target dir 31 GB -> 13 GB, which also fixes the Linux job's craft-fonts
  build running out of disk.
- The corpus job's --release build drops thin LTO and the single codegen
  unit: 2.7x faster to build (744 s -> 273 s); it was 23.5 of 37 minutes.
- Only main saves rust-cache entries (PRs restore main's), with
  cache-on-failure, so per-PR copies no longer evict main's caches from
  the 10 GB budget.
- The Linux job deletes unused image toolchains in the background.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:46:00 +07:00
BFlat 8ebdd36769
Tools leave hidden layers alone: painting or moving a hidden layer is refused (#571) (#872)
* Tools leave hidden layers alone: painting or moving one is refused (#571)

Photoshop refuses to paint on, fill or move a hidden target layer ("the
target layer is hidden"). The painting, retouching, Paint Bucket, Gradient
and Move commands now fail with that message when the target layer, or a
group around it, is hidden (alpha channels and Quick Mask still paint), and
the canvas refuses the press so no live stroke draws first. Auto-Select
already skipped hidden layers; a test now pins it.

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

* Hidden layers: the live Gradient still works over a hidden target

Review on #872: the live Gradient adds a Gradient Fill layer above the
target rather than painting it, which Photoshop allows over a hidden
layer. Only the classic Gradient is refused now, and a test covers the
live one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:45:55 +07:00
Pathos0925 9df3a481ee
New adjustment and fill layers take the active selection as their mask (#1250) (#1282)
Photoshop masks a new adjustment or fill layer with the active selection, so the
adjustment acts on the selected area only. `layer.newAdjustmentLayer.*`,
`layer.newFillLayer.solidColor`, `.gradient` and `.pattern` inserted the layer
without reading `doc.selection`, so select → New Adjustment Layer gave an
unmasked adjustment over the whole image. The Gradient tool's fill-layer
command already did the right thing; its inline code becomes the shared
`commands::selection_mask`, used by every creator. The selection stays active,
as after Layer › Layer Mask › Reveal Selection.

Tests: each command at 8, 16 and 32-bit gets a linked, enabled mask that reveals
the selection and hides the rest, the composite outside the selection is
untouched, undo removes the layer; and no mask without a selection.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:42:23 -04:00
Jamie Pates 3952fcef79
Resolve issue that makes Photocraft save various file formats incorectly as a `.psd` (#1178) 2026-10-08 15:42:17 -04:00
MokuLakshithReddy 7de2b5f20e
Nearest Neighbor: sample single source pixels on reductions without averaging (#1114) (#1141) 2026-10-08 15:42:11 -04:00
BFlat ab4920a4df
Ctrl/Cmd-scroll pans sideways; canvas scroll bars always shown (#635, #620) (#1069)
As in Photoshop, the wheel scrolls vertically, Shift or Cmd/Ctrl + wheel
scrolls sideways and Alt + wheel zooms; Zoom with Scroll Wheel swaps the
wheel and Alt + wheel. egui no longer turns Cmd/Ctrl + wheel into zoom,
so only a real pinch (Event::Zoom) zooms. Windows delivers a touchpad
pinch as Ctrl + wheel in fractions of a notch: the raw input hook turns
those back into pinch events, so pinch-zoom keeps working there while
Ctrl + a notched mouse wheel pans.

The canvas scroll bars are always shown in Standard Screen Mode (none in
the full screen modes, as in Photoshop). With Overscroll on, an image
that fits the window can be scrolled edge to edge with them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:42:06 -04:00
BFlat 938c079d68
Layers panel: Alt-click the line between two layers clips the upper one (#967) (#1066)
Holding Alt (Option) over the line between two layers in the Layers panel
shows the clip cursor; a click runs Layer > Create Clipping Mask on the upper
layer, or Release Clipping Mask when it is clipped already, as in Photoshop.
The line only counts between siblings that are next to each other in the
stack (not inside an open group or across filtered-out rows), and stays
clear of the eye column and the effects triangle. The cursor is the Lucide
corner-right-down icon.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:42:00 -04:00
Ian Swope e06c03344a
Holding a number key sets one digit's opacity, not a two-digit value (#953) (#1030)
opacity_keys::handle ignored egui's `repeat` flag, so the first auto-repeat
of a held digit arriving within the 800 ms two-digit window read as a
second typed digit: holding 4 set 44%, and holding 0 flipped between 100%
and 0%. Repeats are now consumed without being counted.
2026-10-08 15:41:55 -04:00
Elvis33LE d438f84225
Shape gradients: honour midpoints, opacity stops, offset, dither and align (#1321)
vector::Paint::from_fill discarded every gradient field beyond stops,
angle, scale and style, so Photoshop files whose shape fills use opacity
stops, colour midpoints, a centre offset (Ofst), dither or no
"Align with layer" rendered with the wrong ramp, alpha, position or
banding. Paint::Gradient now carries the full ramp (sorted stops,
midpoints, opacity stops, dither) and the compositor's geometry
(centre offset, whole-pixel end-point snapping, pixel-corner sampling,
the align frame choice: shape bounds or canvas), with shape strokes
painting through the same path.

The vector crate cannot depend on compose, so the ramp and geometry
mirror effects::gradient_units/gradient_t, fill_layout::gradient_layout
and gradient_fill::{Ramp, midpoint_remap, dither}. A parity test in
compose (which sees both) renders each non-default field, style and
frame as a shape layer and as a fill layer and requires equal pixels;
from_fill binds every Fill::Gradient field — no ".." — so a new field
fails to compile until the shape renderer honours it.
2026-10-08 15:41:50 -04:00
才倾梦华V 3dd4fd1574
Keep the Background position lock after Flatten Image (#1238) 2026-10-08 15:41:44 -04:00
wstock af3c99d817
fix(engine): Clone Stamp and Healing Brush refuse a far source instead of slicing out of bounds (#1111) (#1165)
`Mapping::offset()` saturates its `as i32` cast and `Rect::translate` saturates
each edge, so a far `source` or `offset` (3e9, i32::MAX) produced a sample
window narrower than the stroke bounds. `clone_sample` then relabelled that
region as the full-size rect and `Region::px` indexed past the buffer:
`range start index 120 out of range for slice of length 92` at
`paint/src/retouch.rs:54`, which the dispatch guard reported as an internal
error. #716 fixed the rotated/scaled branch (`source_rect`); this is the pure
translation branch.

`clone_sample` is now fallible: when the translated window does not keep the
stroke rect's size, the command returns a bad-params error naming the offset
and the document and history are untouched. A source far outside the canvas
but inside the i32 range still clones transparency, as before.

Test: `clone_and_heal_with_a_far_source_are_plain_errors` (both tools, the
issue's inputs, a negative far source, the untouched-layer and no-history-step
assertions, and the in-range far source that must keep working).

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:41:38 -04:00
wstock 4860cef703
Absolute colorimetric refuses a zero or non-finite media white (#972) (#1125)
connection() scaled the PCS by src.media_white() / dst.media_white() with
no check on the divisor, so a v4 profile whose wtpt tag holds zeros (parsed
and stored verbatim) gave a NaN scale, and every colour through a direct
absolute transform or a paper-simulating proof came out NaN. The transform
is now refused with CmsError::Invalid when either white has a zero,
negative or non-finite component; the other intents never divide by it and
are unchanged.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:41:32 -04:00
BFlat fa186c59b9
Paste into a targeted layer mask or channel instead of a new layer (#1035) (#1078)
With the layer mask targeted (its thumbnail clicked, or Alt-clicked to view
it), Edit > Paste, Paste in Place, Paste Into and Paste Outside now paste
the clipboard's luminosity into the mask, placed as a paste places it, over
the mask's values (the pasted pixels' transparency, and the selection for
Paste Into / Outside, let the mask show through). One history step, no new
layer, at 8, 16 and 32 bits. A targeted alpha channel or Quick Mask takes
the paste the same way, as in Photoshop.

The paste commands now follow the target like the fills and filters do
(channel_cmds::routed), and accept a "target" param.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:41:26 -04:00
jason4short 7e40b40fb0
Edit Contents switches to the already-open contents document (#1037)
* Edit Contents switches to the already-open contents document

Opening a smart object's contents a second time opened another copy.
It now activates the document already linked to that smart object.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* rustfmt smart_cmds.rs

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 15:41:19 -04:00
wstock 3f8536868e
image.analysis.recordMeasurements: refuse an area too large to measure instead of overflowing (#1184)
Fixes #933. With no selection the whole document is measured, and
`selection_mask` sized its mask with `r.width() * r.height()` in u32. A legal
65536×65536 document (2^32 pixels) overflowed the product, so `run` panicked
and dispatch reported an internal error; in release the product wraps to 0,
`gray_image` still renders the full region (~64 GiB of f32 RGBA) and
`measure_features` then indexes the empty mask.

`selection_rows` now computes the pixel count in u64 before anything is
allocated and refuses more than `MAX_MEASURE_PIXELS` (400 MP) with an error
that says to select a smaller region; the mask is sized from that checked
count. The measurement label counter is also bumped only after the rows are
computed, so a failed measurement no longer skips a number.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:33:18 -04:00
TiredIsa 7567a20ac3
fix(engine): preserve Unicode in variable data-set CSV (#1062) 2026-10-08 15:33:11 -04:00
jason4short 0ff15f9ed9
Space untimed segments by distance when Spacing is off (#1036)
With Spacing unchecked, dabs are spaced by pointer speed, and a segment
without timestamps got a single dab at its end point. The canvas sends no
timestamps, so a Shift-click straight line with such a brush (the Eraser
with 10% smoothing gives start, 90% and end points) left three separate
dabs instead of a line. Untimed segments now fall back to distance spacing.

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-08 15:33:05 -04:00
yang 7fec6e9469
Brush Scatter above 1000 % is rejected naming the value, and the renderer caps it so dabs stay finite (#977) (#1034)
tools.setBrush and stroke brush patches accepted any Scatter amount. The paint path
multiplied it by the radius in f32, so 1e38 overflowed the dab centre to infinity, and
dab_rect's unchecked i32 arithmetic then panicked (wrapped to an empty rect in release).

Brush validation now rejects primary and Dual Brush Scatter outside 0..=MAX_SCATTER (10,
the slider's 1000 %) with a BadParams naming the value, before the session brush changes
(also between setBrush's two merges, so infinity is named rather than reported as null).
bounded_for_render caps both amounts for direct paint API users (NaN scatters nothing),
and the dab rectangle, now one helper shared with the retouch tools, saturates.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:33:00 -04:00
lionel 69c01c1bff
Startup: preserve the GPU backend after windowing errors (#1228)
* fix(startup): preserve GPU backend after non-graphics errors

* fix(startup): retain prior crash evidence on windowing errors
2026-10-08 15:32:54 -04:00
Chris Walden acddca58b8
Options bar: the airbrush, smoothing options and symmetry buttons work (#1177) (#1214)
In the Photoshop themes (the default ProMedium included) the Brush and
Eraser options bar drew three icon buttons that did nothing when clicked.
The engine already supports everything behind them:

- Airbrush toggles the brush's Build-up (lit while on).
- The gear beside Smoothing opens Photoshop's smoothing options: Pulled
  String Mode, Stroke Catch-up, Catch-up on Stroke End and Adjust for
  Zoom. They edit the brush like the Smoothing % does, so they stay per
  tool and reach the live stroke and the commit.
- The symmetry button opens a menu with Symmetry Off and every path the
  Paths panel lists (saved paths, the Work Path, the selected layer's
  shape path or vector mask), running paint.symmetryFromPath /
  paint.symmetryDisable. It is lit while symmetry is on; with no path it
  says how to make one.

The three buttons get accessible names (brush_picker::named), and
"Symmetry Off" is added to the twelve complete catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:32:49 -04:00
Streetblock 15cfdcd9e8
docs: refresh implemented tool status in the roadmap (#1205) 2026-10-08 15:32:43 -04:00
Armaan Sandhu dab2a8472b
Median, Dust & Scratches: sliding histogram instead of a selection per pixel (#901)
Median gathered every sample of the disc (about pi r^2 values) for each pixel and channel and selected the middle one, so the work grew with the square of the radius. For radius 2 and up, rank each channel's window samples by total_cmp (8-bit samples by level), keep a histogram of the disc's ranks that slides in a serpentine over the tile (one end of each disc row or column out, the other in, per step), and track the median rank as it moves (Huang), with block counts so it crosses large empty rank ranges a block at a time. The value picked is the sample the selection picks, so output is bit-identical at every depth. Radius 1 keeps the direct selection (now median_direct), which is also the test oracle.
2026-10-08 15:32:36 -04:00
yang 0954b8c065
Link Layers no longer overflows on a stored u64::MAX link group and never joins an unrelated group (#961) (#1022)
layer.linkLayers picked the new link group as max(link_group) + 1, computed even
when unlinking. A .pcraft or PSD can store u64::MAX, so the addition panicked in
checked builds and wrapped to 0 in release, where it could merge the selection
into an existing group 0.

Add Document::unused_link_group: max + 1 when it fits, otherwise the smallest
free id from 1, never wrapping. Link Layers uses it only when linking and returns
an error if no id is left. Also read the first selected group and the member
counts without indexing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:32:30 -04:00
yang 533c46f7d8
gradient.fill.stop rejects an out-of-range index with BadParams instead of overflowing (#958) (#1000)
The midpoint action checked `i + 1 >= v.len()` on the raw `index` param,
so an index of usize::MAX overflowed (a panic with overflow checks on, a
silent no-op success with them off). The shared index parser now takes
the number of addressable entries and rejects an index past them before
any arithmetic, for every action (move, delete, color, opacity, midpoint)
and both kinds, so all of them validate the same way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:32:23 -04:00
callmeBalloch 508fafd92d
AF.3: compare image sizes as the unsigned values they are
Clippy (-D warnings) flagged an always-false comparison and a no-op
cast in the Affinity image resampler after Rect::width became u32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 19:13:50 +00:00
Brandon Thomas dd555217ac
Slices test: as_chunks for the fixed 3-wide rows (clippy chunks_exact_to_as_chunks) (#1374)
Follow-up to #991: Rust 1.99's clippy flags chunks_exact with a constant
size, which turns the workspace clippy gate red.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:06:06 -04:00
yang e1a874d9e9
Timeline: out-of-range duration and fps are rejected; stepping never panics (#1009) (#1023)
timeline.create and timeline.setProps kept any `duration` (up to u64::MAX) and
any `fps`, and timeline.nextFrame/previousFrame then moved the playhead with
`(current as i64 + delta).clamp(0, duration as i64 - 1)`, which panics when the
upper bound goes negative (duration 0 or above i64::MAX) or overflows.

Both commands now reject a `duration` outside 1..=1000000 and an `fps` that
isn't finite and in (0, 1000] with BadParams before anything changes. Stepping
moves to a saturating Timeline::step, safe on any stored timeline (e.g. one
loaded from a file with duration 0), and a non-finite stored fps falls back to
30 like a non-positive one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:39 -04:00
yang 815b4ee0f9
Place Scale Marker: a document under 8 px tall returns an error instead of panicking (#932) (#1014)
The bar height was clamped to [2, h/4], and f64::clamp panics when h/4 < 2,
so any image shorter than 8 px panicked inside the command's run closure.
Reject such images with a bad-params error before anything changes, like the
existing bar-width check. Also keep the bar on the canvas when the label under
it, or the left margin, would push it off a small image.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:32 -04:00
yang 0c2c268c49
image.crop rejects an origin or far edge that can't be moved within i32 instead of panicking (#959) (#1013)
crop_doc negated the crop origin unchecked, so x or y = i32::MIN overflowed;
an origin one past i32::MIN pushed layer pixels past i32::MAX and panicked on
a length mismatch while translating surfaces; and x + width past i32::MAX
saturated, silently cropping less than asked. crop now checks the far edge,
crop_doc checks the negation, and translate_doc checks that every surface
still fits before moving anything; all return BadParams naming the values.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:25 -04:00
Nigel Ihza Firdausy fa1f0548fb
fix(engine): saturate layer slice outsets at coordinate limits (#998) 2026-10-08 15:05:17 -04:00
Nigel Ihza Firdausy bdff608a0c
fix(engine): divide wide slice rectangles without integer overflow (#991) 2026-10-08 15:05:09 -04:00
Ian Swope 874867461d
file.new at 300000x300000 no longer fills ~360 GB of tiles before returning (#705) (#900)
Surface::fill_rect materialized every tile it touched, so a new document's
background cost one 256x256 tile per tile slot: ~360 GB for the 300000x300000
size the command accepts. Fully covered tiles now share one Arc'd tile, so a
solid fill costs one tile plus the tile index; edge tiles are written as
before, and copy-on-write splits a shared tile off when it is edited.
2026-10-08 15:04:57 -04:00
jason4short 059546680c
Layer copies are numbered: "Layer 1 copy 2", not "Layer 1 copy copy" (#897)
Duplicating a layer (Layer › Duplicate, ⌥-drag with the Move tool, Layer
via Copy without a selection, New Smart Object via Copy, the Edit menu's
duplicate, Select and Mask's new layer) names the copy with
`Document::copy_name`: "Layer 1 copy" first, then "Layer 1 copy 2",
"Layer 1 copy 3"…, unique in the document. A name already ending in
"copy" or "copy N" is numbered from its root instead of growing another
"copy"; names that merely contain the word keep it.

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-08 15:04:52 -04:00
jason4short cf758c5b04
New Document: a Clipboard preset with the clipboard image's size (#891)
When the clipboard holds an image (pixels copied in the app, or an image
copied in another app), File › New offers a Clipboard preset first under
the Recent blank-document presets, at that width and height and 72 ppi,
and selects it, so Create makes a document the size of the clipboard.
Without an image the dialog opens as before. Opening the dialog is an
explicit request, so the OS clipboard is read then, as for a paste.

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-08 15:04:45 -04:00
Ian Swope dc256b8b39
A PSD layer with bounds but no channel data no longer aborts on a 360 GB allocation (#755) (#885)
record_surface sized the interleave buffer from the layer rectangle alone,
so a few-hundred-byte file declaring a 300000x300000 layer with no
channels made import allocate ~360 GB and abort. The channel decoders
already bound their output by the bytes present, so a buffer is now built
only when at least one channel decodes; otherwise the layer imports empty
with a warning.
2026-10-08 15:04:40 -04:00
Tanish Manku 5049a1d062
Never crash on hostile numbers: LUT sizes, Quick Selection points, link group ids (#869)
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
  2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
  indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
  compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
  saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
  wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
  largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
2026-10-08 15:04:34 -04:00
RicSchonfelder 968b1f2c4f
i18n: fix pt-br label collisions (Feather/Diffuse, Liquify/Dissolve) (#833)
Per maintainer review (#833): keep Feather as Difusão…, Diffuse becomes
Difundir…, and Liquify becomes Liquefazer… (Licuar is Spanish).
Separates the Fast menu and Stylize menu entries that were colliding in pt-BR.
2026-10-08 15:04:28 -04:00
bfenty f9b72468b4
docs: stop linking readers to the private craftrules repo (#493) (#779)
The contributor docs offer github.com/storytold/craftrules as the place
to read the font rules and release playbook, but the repository
returns 404 to everyone outside the organization, so the cited text is
unreachable for outside contributors. Keep the sibling-checkout paths
and say plainly that the repo is not public and a maintainer can supply
the rules relevant to a change.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-08 15:04:21 -04:00
BFlat 3f740ccfb3
Layers panel: hovering a blend mode previews it live on the canvas (#970) (#1081)
Hovering a mode in the Layers panel's blend-mode list previews it on the
selected layer; moving off the list reverts, and only a click applies it
(one history step), as in Photoshop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:04:04 +07:00
Claude 4d4ab93f83 Select › Modify: bound the canvas-edge margin and translate Apply At Canvas Bounds
The margin Modify pads a canvas-edge selection with was twice the radius
for every operation, so Feather 1000 added 2002 px a side. It now pads by
each operation's reach (Feather 1.5 r, the others r) and refuses a padded
mask over 2^30 pixels instead of aborting on the allocation. The new
dialog option is translated in every shipped language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:59:24 +07:00
Marcelo Cárdenas d7859f17c7 Select › Modify: Apply effect at canvas bounds, so Contract/Feather/Border work after Select All (#1277)
Contract, Smooth, Expand and Feather gain Photoshop's "Apply effect at canvas bounds" option
(applyAtCanvasBounds, default off). On, the canvas edge counts as a selection edge, so Select All
then Contract shrinks from the edges and Feather softens them; off, the selection continues past
the canvas, as in Photoshop. Border always follows the canvas edge, so a Select All borders to a
band inside it instead of an empty selection.

The mask is padded (empty or edge-replicated) only when the selection touches the canvas edge,
so selections clear of it cost nothing extra.
2026-10-09 01:59:24 +07:00
Claude c5d50b4390 Free Transform: a typed W/H stops at the largest document side
1000000 % on any layer could ask the transform for an image millions of
pixels wide, more than can be allocated. The W and H fields now reach as
far as the box stays within 300000 px (the document limit), at most
1000000 %, and a linked change keeps the other side within its limit too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:58:43 +07:00
Marcelo Cárdenas bb723b12eb Free Transform: W/H fields reach 1000000% and X/Y the 300000 px document limit (#1267)
The options bar clamped W/H to ±10000% and X/Y to ±30000 px, while the engine takes any scale
and documents go up to 300000 px a side. Overlays on large stitched mosaics need more. The limits
are now named constants: ±1000000% (a 300 px layer can span the largest document) and ±300000 px.
2026-10-09 01:58:43 +07:00
callmeBalloch ef870103f8
Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2 2026-10-08 18:58:33 +00:00
callmeBalloch 08a812e568
AF.3: open Affinity 1 and 2 extensions everywhere and match the new fallback warning
File › Open, the web build, batch folders and the CLI now pick up
.afdesign, .afphoto and .afpub, which the native reader handles; the MCP
test checks the preview fallback's new warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 18:58:33 +00:00
Claude 3bab0d4f52 PSD text fill: keep the fix and its tests, drop the proof-only screenshots and example
The Photopea screenshots, their ATTRIBUTION row and the example that made
them documented the bug for review; the unit tests in crates/text cover
the behaviour, so they are not needed in the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:57:17 +07:00
Rage 7bb971f77c Fix PSD text fill after edits in Photopea 2026-10-09 01:57:17 +07:00
Nigel Ihza Firdausy 7d78e1dd2c
Export Preferences: WebP quality and lossless settings for Quick Export (#1260) (#1279)
* feat(export): persist WebP lossless mode and quality in export preferences (#1260)

* fix(export): apply configured WebP quality to Quick Export output

* fix(ui): show format-specific JPEG and WebP export preference controls

* fix(ui): use WebP quality and lossless preferences in Export As dialog

* test(export): verify WebP quick export mode, quality, validation, and legacy prefs

* style: format WebP export regression test as rustfmt expects

* style: apply rustfmt layout to conditional WebP preference labels

* docs(scorecard): account for two WebP export preference fields (#1260)
2026-10-09 01:57:01 +07:00
Nigel Ihza Firdausy a5f9029c20
File menu shows the configured Quick Export format (#1259) (#1269)
* fix(ui): show configured quick export format in File menu (#1259)

* fix(ui): preserve localized quick export labels for selected format
2026-10-09 01:56:57 +07:00
Nigel Ihza Firdausy 184ec31f1c
Hidden ruler guides no longer intercept canvas input (#1258) (#1268) 2026-10-09 01:56:54 +07:00
marcardenas d8dfb60b9a
Gradient tool: a handle dragged past the 10% scale floor stops; the other handle stays put (#1243) (#1306)
Dragging one live-gradient handle towards the other clamps the fill's scale at 10%. For Linear the
centre was still taken from the midpoint of the requested points, so the
clamped gradient was re-centred and the handle that wasn't being dragged slid along the line.
gradient.fill.set now shifts the clamped layout so the handle not given keeps its place. The same
apply_set drives the drag preview, so the canvas matches.
2026-10-09 01:56:51 +07:00
marcardenas eb4b0f5e36
Filter › Last Filter applies to the active layer, not the layer the filter first ran on (#1270) (#1307)
The Filter Gallery, Camera Raw and Adaptive Wide Angle dialogs record the layer they ran on in
the command's params. Last Filter replayed the journal entry verbatim, so it filtered that layer
again (possibly hidden, or deleted) and appeared to do nothing on the layer you were looking at.
It now drops the recorded `layer`, so it applies to the current layer as in Photoshop.
2026-10-09 01:56:46 +07:00
Arian Pashae 93694cf2f6
chore: remove accidentally committed fb-*.log files and ignore *.log (#1155) 2026-10-09 01:56:40 +07:00
BFlat 437b25bea9
A Distort corner stops before the box folds over (#1323) (#1332)
Distort and Perspective move a corner along the drag only as far as the
box stays convex, stopping just short of flat, as Photoshop and Photopea
do, instead of folding it into a bow tie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:52:27 +07:00
BFlat b3d10f9a48
The Studio themes show the Move tool's Auto-Select toggle (#1275) (#1333)
The Pro themes already had Photoshop's Auto-Select checkbox; the Studio
themes showed only a hint for the Move tool, so Auto-Select could not be
turned off there. Both now have the toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:52:19 +07:00
BFlat 3e8d788441
Delete and Fill Selection on the selection tools' right-click menu (#1286) (#1331)
Right-click with a selection tool and choose Delete and Fill Selection
to fill the selection from its surroundings with Content-Aware Fill's
default settings, without the dialog. Engine command
edit.deleteAndFillSelection, one undo step, translated in every shipped
language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:51:47 +07:00
callmeBalloch ae0313d325
AF.3: native Affinity import with artboards, vectors, text and images
photocraft-affinity now reads native Affinity documents (container
versions 8-12): the FAT and revision chain, stored/zlib/zstd entries
with predictors and CRC checks, the tagged object stream in doc.dat,
and the document model (pages, artboards, layers, groups, curves,
geometric and compound shapes, fills, gradients, strokes, text, placed
images, pixel layers, masks and embedded-document caches). Every size
is bounded and malformed input returns an error.

photocraft-io maps it to an RGB8 document with artboards, groups, shape
layers, type layers, embedded smart objects, pixel layers and
vector/layer masks; what can't be mapped is listed in the warnings. The
embedded preview is now only a fallback when the native data can't be
read, and only that preview is refused by Place and warningless imports.

`cargo xtask corpus --affinity` pins 21 public CC0/MIT documents,
sha256-verified; the io corpus test compares each import with the
thumbnail Affinity saved in it (mean difference 0-4.6 of 255).
Affinity export is not implemented: no Affinity installation was
available to validate written files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 18:46:22 +00:00
DWAA1660 c833294e34
PSD export: write empty layer and mask channels as raw (#1290) 2026-10-08 14:31:24 -04:00
Nigel Ihza Firdausy 5054308d0b
fix(ui): allow removing the last polygonal lasso point (#1262)
* Allow removing last polygonal lasso vertex without editing layers

* Route lasso Backspace ahead of destructive edit shortcuts

* Use right-click to retract polygonal lasso points instead of opening menu

* Regress lasso vertex removal via keyboard and actual canvas right click

* Apply rustfmt layout reported by Linux CI

* Apply rustfmt layout reported by Linux CI
2026-10-08 14:31:17 -04:00
Nigel Ihza Firdausy f9ccdda1e2
fix(ui): remember previously applied New Guide Layout values (#1261)
* Remember last applied New Guide Layout parameters in UI state

* Save guide layout preferences only after successful application

* Cover successful, cancelled and failed guide layout dialog state

* Avoid overlapping borrows when reopening guide layout dialog
2026-10-08 14:31:10 -04:00
Elvis33LE f5080f9774
ui.window.open: an invalid document index is an error, not a side effect (#1133)
The document parameter's set_active result was discarded, so an
out-of-range index still switched nothing, changed nothing, and the
window opened for whatever document happened to be active - a caller
was told success for a document that does not exist. Now the call
errors with the index and leaves the active document alone.
2026-10-08 14:31:03 -04:00
wstock 317d946f45
PSD: a spec-order mask record is not misread as Photoshop's order when that reading is implausible (#949) (#1129)
real_before_parameters picked Photoshop's order (real mask first) whenever
the record length fit the parameter-flags byte at offset 36, even when that
byte was just part of the real rectangle. A density-only record in the
spec's order then read its density as the real mask's background and lost
the parameter. When both orders fit, Photoshop's reading now has to be
plausible (background 0 or 255, known parameter flag bits) or the spec's
reading with a real mask wins.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 14:30:56 -04:00
Ian Swope 8f5a0817e4
Applying a text data set re-renders the type layer instead of blanking it (#990) (#1025)
apply_to_doc replaced a text-replacement variable's text and set the layer's
render cache to None, but nothing rebuilt it. The compositor draws a type
layer from that cache, so the text vanished from the canvas after
image.applyDataSet and from every file written by
file.export.dataSetsAsFiles. The layer is now re-rendered with
type_cmds::refresh, as type.edit does.
2026-10-08 14:30:50 -04:00
Armaan Sandhu 3179093985
Motion Blur: apply the samples as one merged kernel over whole rows (#902)
Motion Blur took distance + 1 bilinear samples per pixel, each through Image::sample with per-tap edge checks, so distance 100 cost about 400 edge-checked reads per pixel and channel. The sample offsets are the same for every pixel, so merge their bilinear taps once into a fixed kernel (each tap an integer offset and a weight) and apply it tap by tap as a multiply-add over whole rows of a premultiplied window, which the compiler vectorizes. This is the same premultiplied mean as before, summed in a different order.
2026-10-08 14:30:43 -04:00
callmeBalloch f94bb1dc9b
AF.1: refresh the lockfile for the 0.5.0 workspace version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 17:10:10 +00:00
callmeBalloch e3932fa7a3
Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2 2026-10-08 16:08:36 +00:00
callmeBalloch 1bb37eda05 AF.1: import bounded Affinity previews with fidelity warnings
Read the indexed version-12 PNG preview with bounds, chunk ordering and CRC checks. Show native-content and resolution warnings, preserve the source through normal and automation opening, and reject auxiliary routes that cannot display the warning. Add format documentation, malformed-input and save-lifecycle tests, native-oracle hooks and parser/import fuzz targets. Editable native layers and Affinity export remain unsupported.
2026-10-08 17:42:23 +02:00
Brandon Thomas e5e3e39752 Bump version to 0.5.0 (from 0.4.1) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:01:32 -04:00
Brandon Thomas bce7e54a63
Release workflow: only signing and draft-release jobs use the release environment (#1220)
version, linux and web read no secrets. With environment: release they were refused on a branch,
so a branch dispatch of release.yml built nothing. Now a dry run builds every unsigned platform;
the signed jobs are still refused and the draft-release job needs them, so a branch can't create
a release. Matches craftrules release/playbook.md §2.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:52:09 -04:00
Brandon Thomas 35f7e99965
README Downloads: where to get it first, then builds with Windows before macOS (#1219)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:50:10 -04:00
Brandon Thomas d2a9295050
README: Downloads section listing every release build, grouped by OS (#1210)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:24:06 -04:00
Brennan Stehling b10b37c14b
macOS: use the native menu bar instead of the in-window menus (#887) (#894)
* macOS: use the native menu bar instead of the in-window menus (#887)

On macOS the menus move from the title bar to the menu bar at the top of the
screen, built with muda from the same rows as the in-window bar (menu_items).
Linux, Windows and the web keep the in-window menus, and so does macOS with
--in-window-menus or PHOTOCRAFT_IN_WINDOW_MENUS=1.

The Mac layout (native_menu.rs, platform-free and tested everywhere):
- a PhotoCraft app menu: About (from Help), Settings (Edit › Preferences),
  Services, Hide, Hide Others, Show All, and Quit, which is still file.exit so
  the unsaved-changes prompt runs;
- a Window menu AppKit knows (Minimize, Zoom, Bring All to Front, the window
  list) and a Help menu with the system search field;
- PhotoCraft keeps Photoshop's ⌘H (Extras), ⌘M (Curves), ⌘W (Close) and
  ⌘, (Hide Layers): Hide and Settings have no shortcut, Minimize is ⌃⌘M.

Key equivalents are handed back to egui as the key presses they were, so
shortcuts run through shortcuts::handle exactly as before: text fields keep
⌘C/⌘V/⌘Z, dialogs and Camera Raw stay modal. A click runs the command like an
in-window click. The menu is updated only when state changes, in place, and
rebuilt only when its structure does.

On every platform:
- menu rows show the shortcut that runs them: 12 live items (Undo, Redo, Cut,
  Copy, Paste, Hide Layers, …) showed none;
- Image › Mode, View › Proof Setup, Proof Colors, Gamut Warning and Isolate
  Layers stay check items with no document open, so opening one doesn't
  change the menu's structure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Native menu: test it against AppKit; fix the Window/Help menus and ⌘R routing

tests/mac_menu_appkit.rs (harness = false, main thread, like the tablet
crate's monitor test) installs the real muda menu and reads AppKit's menu
bar back: the PhotoCraft app menu, Quit ⌘Q, no Exit in File, the Window and
Help menus registered with NSApp, and Layer › New › Layer… enabled in place
(same NSMenu) once a document opens. It then sends a real ⌘R key-down
through -[NSMenu performKeyEquivalent:], which must reach egui as ⌘R and run
View › Rulers once, and chooses Rulers by click and by ↩ in the open menu,
which must run as clicks.

It found two bugs, fixed here:
- Window and Help were never registered: muda looks the submenus up in the
  installed main menu, so set_as_*_menu_for_nsapp must come after
  init_for_nsapp. The Window menu didn't list windows.
- AppKit matched ⌘R to Filter › Lens Correction (⇧⌘R), and the menu handed
  egui the matched item's shortcut, so ⌘R ran Lens Correction. Key
  equivalents now go back with the modifiers actually held.

Also a kittest: the title bar draws no menu titles with the Mac menu bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Mac app menu: Language and Appearance, Hide on ⌃⌘H (aligned with VectorCraft)

App settings live in the app menu, as on a Mac and as VectorCraft does:

- Language ▸ after Settings: Auto, then each UI language in its own name,
  the interface.language preference checked; choosing one runs prefs.set
  (unknown codes are refused).
- Appearance ▸: the themes (now check items, in-window too) and Next Theme
  move from Window › Theme to the app menu.
- Hide PhotoCraft takes ⌃⌘H when ⌘H is View › Extras, as Photoshop does
  (Minimize already takes ⌃⌘M).

The other Craft apps use the same app menu, Window menu and
--in-window-menus / <APP>_IN_WINDOW_MENUS opt-out.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-08 20:22:28 +07:00
Brandon Thomas c9a7d26f6a Web build: ICU complex-script dictionaries native-only; bump version to 0.4.1
The v0.4.0 release failed its web job: the wasm was 26,365,303 bytes, over the
24 MiB gate in packaging/web/package.sh and Cloudflare's 25 MiB per-file cap.
The largest single item was ICU4X's CJK segmentation dictionary (3.8 MB),
pulled in by parley's complex-scripts feature. Enable that feature on native
targets only; the web build falls back to the generic Unicode rules for
Japanese word boundaries and Thai/Lao/Khmer/Burmese line breaking.

wasm: 26,368,656 -> 22,578,266 bytes (21.5 MiB), measured with trunk build --release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:03 -04:00
Brandon Thomas 173fc90ecb Bump version to 0.4.0 (from 0.3.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:12:20 -04:00
Brandon Thomas 5896f0b63e
Preferences: colour settings take a typed #rrggbb value (#668 item 2) (#1001)
* Preferences: colour settings take a typed #rrggbb value (#668)

The canvas colour (and every colour preference) showed its hex value as a
label next to the swatch. It's now an editable field: a valid value sets the
colour, a partial one is kept while typing.

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

* rustfmt

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:01:43 -04:00
Brandon Thomas 4c4c58d6e3
Clone Stamp / Healing Brush: the source crosshair shows only while painting (#668) (#969)
Setting the source with Alt-click left a + on the canvas until the next stroke.
As in Photoshop, the crosshair now shows where the stroke samples only while
the stroke is painted.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:01:38 -04:00
Brandon Thomas 3d003bd9d0
Windows: selection and other crosshair tools stay visible over grey (#737) (#954)
Windows' crosshair cursor inverts the pixels under it, so it vanishes over
mid-grey (the pasteboard, many photos). On Windows the canvas now draws a
black-and-white crosshair itself and hides the system one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:01:33 -04:00
Brandon Thomas d64f809e4d
Layer via Copy, Copy and Cut read only the selected area: 33x faster on 24 MP (#668) (#985)
lift() converted the whole layer to a format with alpha before reading the
selection's rectangle, so on a 24 MP Background every Layer via Copy cost about
a second whatever the selection's size, and it sampled the selection mask one
pixel at a time. It now reads just that rectangle and the mask's rectangle.

6000x4000 Background, 2000x1500 selection, release: Layer via Copy 984 ms ->
30 ms. Select All then Layer via Copy: 212 ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:48 -04:00
Brandon Thomas 1ae11a4bb1
Shape tools: Shift and Alt pressed during the drag constrain it, as in Photoshop (#668) (#984)
Shape drags only read the modifiers held at the press, so pressing Shift
mid-drag (the usual way) didn't square a rectangle or keep a polygon's
proportions, and Alt mid-drag didn't draw from the centre. They now follow
the modifiers held at each pointer event, like the marquees.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:44 -04:00
Brandon Thomas 74c7980146
Type: Cmd/Ctrl+T while typing shows or hides the Character panel (#668) (#983)
It passed through to Edit > Free Transform, which started transforming the
layer being typed into. As in Photoshop, it now toggles the Character panel
and editing continues.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:39 -04:00
Brandon Thomas dd963b4b44
Crop tool starts with a frame around the canvas (or the selection), as in Photoshop (#668) (#971)
Picking the Crop tool showed nothing until a frame was drawn. It now frames the
whole canvas, or the selection's bounds when there is one, and frames again
after a crop is cancelled or committed. Like Photoshop, a drag inside the
untouched frame draws a new one, its handles resize it, and committing it
does nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:34 -04:00
Brandon Thomas 997acf5885
Layers panel: drop layers on the footer's New Layer, New Group and Delete buttons (#736) (#965)
As in Photoshop, dragging a layer row onto New Layer duplicates it, onto New
Group groups it and onto Delete deletes it. A row that is part of the
selection carries the whole selection; any other row goes alone. The button
under the pointer is highlighted during the drag.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:29 -04:00
Tanish Manku 11f6f00ccc
Variables dialog: malformed entries can't crash an edit (#875)
`ui.dialog.set` can store any JSON in the dialog's `__variables` field, and the Define and Data
Sets pages write fields into its entries (`d["name"] = …`, `set["name"] = …`), which panics when an
entry is a number or a string. The dialog now normalizes the state each frame, keeping only object
entries in `defs`, `dataSets` and each set's `values`.
2026-10-07 23:58:31 -07:00
Tanish Manku 62b0d0bd60
Automation: an allowed command can't run a denied one on its behalf (#861)
Untrusted sessions (MCP, the control channel) checked only the top-level command and the steps of
`actions.play`. Any other command that runs commands internally ran them unchecked, so the allowed
`file.automate.conditionalModeChange` could run `image.mode.*`, which the desktop gate denies
because it resolves the user's configured colour profiles.

`Session::dispatch` now applies `Session::authorize` to every command before any side effect,
nested ones included, which closes the class rather than this one command. Sessions without a
gate (local UI, `photocraft-cli run`) are unchanged.
2026-10-07 23:58:26 -07:00
Brandon Thomas eec4af6551
Fix main: classify the Direct Selection path commands; Czech for "{n} layer" (#1074)
Two tests on main fail on every OS:
- automation: path.moveAnchors/moveHandle/bendSegment/convertPoint (#865) take
  a document vector path ("name"/"layer"), not a host file, so they join the
  list of vector-path commands the filesystem-param audit skips.
- xtask i18n: the plural "{n} layer|{n} layers" key reached every complete
  catalog but cs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:36:08 -04:00
BFlat 452672765d
Photo Filter and gradient glows render like Photoshop (#442, #443) (#850)
* Photo Filter and gradient glows render like Photoshop (#442, #443)

Photo Filter multiplied encoded RGB by the filter colour and rescaled to Rec.601 luma. Photoshop
multiplies the pixel's D50 XYZ (relative to white) by the filter colour's, mixed by density, and
Preserve Luminosity restores the encoded luminosity like the Luminosity blend mode (SetLum +
ClipColor); 32-bit documents keep luminance Y. rgb16 photo-filter.psd now matches within 0.6/255
(was 125/255); rgb8 mean error 0.3/255 (was 20.8).

Outer and Inner Glow gradients were laid out across the canvas (a vertical linear gradient), so
the colour bands ran top to bottom. Photoshop colours the glow at strength v with the gradient at
1 - v (first stop at the edge) and the glow is opaque from v = range^2. outer-glow-gradient.psd:
mean 1.4/255, max 9.9 (was 252).

CPU and GPU paths updated together; photoshop oracle floor 132 -> 133.

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

* Merge origin/main; regenerate docs/scorecard.md

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 23:49:10 -04:00
BFlat 01cd6fffa2
Layer › Smart Objects › Convert to Layers (#535) (#829)
* Layer > Smart Objects > Convert to Layers (#535)

Unpacks a smart object's contents back into layers at its placement:
several layers land in a group named after the smart object (carrying
its opacity, blend mode, masks and style); a single layer replaces it
directly when those fold in exactly. Contents are conformed to the
document's mode, profile and depth; smart filters are discarded.
Also in the Layers panel context menu and the Properties quick actions.

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

* Parity: Convert to Layers is live (627 of 627); raise FLOOR

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

* Convert to Layers: pass no inherited group locks to transform_layer (#847 added the parameter)

The layers are transformed inside released(), which lifts every lock in their tree.

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

* it: translate Convert to Layers (Italian landed after this PR was written)

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 23:44:21 -04:00
BFlat 20fb1903dd
Drag layers onto another document's tab to copy them there (#589) (#851)
* Drag layers onto another document's tab to copy them there (#589)

New engine command layer.copyToDocument copies layers into another
open document as one history step there, converting them to the
destination's colour profile and bit depth, and places them at the
same canvas position, centred, on a point, or at an offset.

In the UI, dragging layers from the Layers panel or with the Move tool
onto another document's tab shows that document (spring-loaded tabs),
and releasing over its canvas drops the copies under the pointer;
Shift keeps the position for same-size documents and centres them
otherwise, as in Photoshop.

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

* Tab labels: keep #832's full-title label on Photoshop-style tabs; the layer_transfer tests find a tab by it

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 23:37:42 -04:00
BFlat a93fb88064
Invert (⌘I) inverts the targeted layer mask (#780) (#838)
* Invert (⌘I) inverts the targeted layer mask (#780)

With the layer mask targeted (its thumbnail clicked, or viewed with
Alt-click), Image > Adjustments and the filters now edit the mask instead
of the layer's pixels, as in Photoshop. Without a selection Invert also
flips the mask's default, so a reveal-all mask becomes hide-all, past the
canvas too. A targeted mask enables these commands on layers without
pixels (adjustment and fill layers), and dialog previews show the mask.

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

* rustfmt

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 23:30:53 -04:00
Brandon Thomas e694208a06
Slice, layer comp and type style ids out of u32 range are rejected, not wrapped onto a real item (#914) (#930)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:13:41 -04:00
Brandon Thomas 5bfadbadd0
Save for Web: slices never overwrite each other, and overlapping slices keep their HTML cells (#908, #909) (#929)
Output file names are made unique (case-insensitively; spacer.gif reserved when
writing HTML). In the HTML table a slice shows only where no later slice covers
it; that visible part is cut into rectangles, each its own cell and image, so a
later overlapping slice's link, text and image are no longer dropped.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:12:27 -04:00
Brandon Thomas bdd0141cf3
Filter Gallery: Angled Strokes paints strokes instead of a smear; Rough Pastels' texture shows in the dark areas (#879) (#942)
Angled Strokes was two diagonal box blurs, so it read as motion blur and
Sharpness barely registered. It now repaints in discrete, staggered strokes
(each takes the colour at its middle), light and dark areas in opposite
directions, and Sharpness spans a visible range. Rough Pastels drowned the
image in an even canvas relief; its chalk strokes are now discrete too, and
the texture shows mostly where the image is dark (the chalk is thick in bright
areas), as Photoshop describes it. A test checks every slider of both changes
the result and that both stay tile-independent at every extreme.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:03:09 -04:00
BFlat 244a99c39f
Windows and Linux: one title bar with the app icon (no stacked OS bar); long context menus scroll (#986)
* Windows and Linux: one title bar with the app icon, like Photoshop

The window had the OS title bar stacked above PhotoCraft's own top bar. On
Windows and Linux the window now has no OS decorations and the top bar is
the title bar, as in VectorCraft and Photoshop on Windows:

- the app icon (brand.rs) leads the bar, before the menus;
- Minimize, Maximize/Restore and Close sit flush in the top-right corner
  (Close turns red on hover; it runs File > Exit, so unsaved work is asked
  about first);
- the free gap between the menus and the controls still drags the window
  and a double-click there maximizes it;
- invisible 5 pt edges (12 pt corners) resize the window.

The right-hand group gets its own rect between the menus and the caption
buttons (egui's MenuBar takes the whole row, so it used to be laid out from
past the bar's end). Narrow windows drop what is also in a menu before
anything overlaps: Discord, then the theme toggle, then search, then the
workspace switcher narrows. macOS keeps its traffic lights.

New theme tokens caption_close / caption_close_text, Minimize / Maximize /
Restore in all 12 catalogs, and a --custom-titlebar flag for the snapshot
example.

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

* Long right-click menus scroll inside the window instead of running off it

widgets::menu_scroll caps a context menu at the visible window height
(clear of a taskbar a too-tall window runs under, #315). egui moves the
popup up to fit, and it scrolls only when it is taller than the window.
Used by the Layers, canvas tool, Channels, Paths and document-tab menus;
the menu bar's menus already scroll (menu_nav::level).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:02:07 +07:00
BFlat 75ff41a26b
Direct Selection tool: reshape pen and shape paths after they're drawn (#790) (#865)
* Direct Selection tool: reshape paths after they're drawn (#790)

Adds Photoshop's Direct Selection tool (A, in the Path Selection flyout) and
the Pen's Cmd/Ctrl (Direct Selection) and Alt (Convert Point) modifiers. It
selects and drags anchors, direction handles and segments of the work path,
the active shape layer's path and a targeted vector mask.

The geometry lives in photocraft_vector::edit; new engine commands
path.moveAnchors, path.moveHandle, path.bendSegment and path.convertPoint
apply it (one history step per drag, drivable over MCP and the control
channel), and the canvas previews a drag with the same code.

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

* Tool::ALL holds 49 tools now that Direct Selection and Magnetic Lasso both landed

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 22:58:31 -04:00
ljkx ec7302f4b3
TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8, part of #215) (#859)
* TIFF: BigTIFF, every IFD and SubIFD, banded parallel decode (FILE-215-8)

Large and multi-page TIFFs now open the way Photoshop opens them, quickly and without a second
full-size buffer, and any directory of the file can be opened on request.

* photocraft-codecs `tiff_ifd`: a bounds-checked directory walker for classic TIFF and BigTIFF
  (LONG8 / IFD8, both byte orders). It follows the whole IFD chain and the SubIFD trees (tag
  330) with a seen-set, a depth limit and a 10 000-directory cap, and checks every value range
  against the file. `tiff_info` lists the pages (size, kind: page / reduced-resolution / mask,
  parent, storage); `default_page` is the first full-resolution page of the main chain, so a
  leading thumbnail is skipped. `decode_tiff_page`, `tiff_orientation`, `tiff_page_orientation`.
* Banded decode: the `tiff` crate reads the chosen directory through a patched byte view (header
  -> that IFD, its next pointer -> 0, WhiteIsZero / palette -> BlackIsZero), so SubIFDs and
  cyclic chains decode. The image is allocated once (fallibly, zeroed in parallel) and each
  strip or row of tiles, across every plane, is decoded straight into it, in parallel on native
  targets, with one capped chunk-sized scratch buffer per worker. New: planar configuration 2
  (was an error), palette images (as RGB), 64-bit integers, associated alpha made straight,
  non-alpha extra samples dropped. Image data cut off by the end of the file decodes with a
  `Truncated` warning. Before the crate sees a file: structural tag values must lie inside it,
  the chunk tables must match the geometry (checked in 64 bits; the crate overflowed and
  panicked on `(h / rows + 1) * planes`), and a decompression-bomb guard refuses files far too
  small for their declared size.
* WhiteIsZero and BigTIFF Orientation keep upstream #746's behaviour (#691, #693) through the
  new decode path; regression tests cover both.
* Encode: `EncodeOptions::tiff_bigtiff` writes BigTIFF; without it, BigTIFF is chosen when the
  file could pass 4 GiB (and a classic write that overflows 32-bit offsets is redone as BigTIFF).
* photocraft-io: `import_tiff_page(name, bytes, page)`; the TIFF branch of import goes through it.
* Tests: codecs `tiff_pages.rs` on hand-built files (`tests/common/tiffgen.rs`): BigTIFF LE/BE at
  8/16/32f, padded tiles and planar strips/tiles, pages and SubIFDs, cycles, the IFD cap,
  truncated and past-EOF strips, huge dimensions / counts / tiles, WhiteIsZero, palette, wide
  formats, BigTIFF write, and a cut / bit-flip / random-mutation zoo (an ignored long run did
  200 000 mutations without a panic); io `tiff_pages.rs`. The `decode_tiff` fuzz target walks
  every page. `tiff_bench.rs` (ignored) times a 30 MP decode against the previous path.
* 30 MP RGB (release, 16 threads, same run): Deflate 344 -> 32 ms, LZW 428 -> 43 ms, 16-bit
  Deflate 497 -> 46 ms, uncompressed 15 -> 10 ms, 256 px tiles 36 -> 10 ms, planar error -> 12 ms.
* Scorecard FILE-215-8 done; README and roadmap updated.

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

* rustfmt the TIFF reader and tests

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 22:37:51 -04:00
Tanish Manku fee08b07b6
CI: craft-fonts run uses the Test step's exact build again (Linux runner out of disk since #800) (#955)
#800 narrowed the craft-fonts test run to -p photocraft-text -p photocraft-ui-egui. A
different package and feature selection resolves a different feature set (no
--features heif), so cargo recompiled the whole dependency graph into the same
target dir on top of the Test step's build, and test (ubuntu-latest) ran out of
disk (No space left on device, linker bus error) on main and every PR.

Back to cargo test --workspace --locked --features heif, which reuses the Test
step's artifacts and rebuilds only the crates that read CRAFT_FONTS_DIR. The comment
says why the selection must match.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:31:43 -07:00
BFlat 772925caf9
Selection tools: Photoshop's full right-click menu (#614, #738) (#831)
Right-click with a Marquee, Lasso, Polygonal Lasso, Magic Wand or Object
Selection tool now lists Photoshop's selection context menu in its order
(Deselect … Fade…), with separators, the menu-bar commands and their
enablement. Without a selection it offers Select All, Reselect, Color
Range… and Load Selection…. Make Work Path… asks for the tolerance first.
All canvas context menus (selection, Pen, Free Transform) now share one
row model, so the control channel lists and chooses them the same way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 22:26:43 -04:00
Brandon Thomas e040376e15 Release CI: macOS job on macos-15-xlarge
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:53 -04:00
Brandon Thomas d55da47e8d
Slice Options and Divide Slice are one undo step, and a rejected one changes nothing (#910, #911, #912) (#928)
An auto slice used to be promoted in its own history step before the command
validated its options, so a rejected slice.set still promoted (and an off-canvas
rect was committed before the error), slice.divide took two undos, and
slice.set folded into any earlier explicit Promote. The promotion now happens
inside the command's own edit, which is all-or-nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:03:55 -04:00
Brandon Thomas 34a87cedea
actionlint: declare the org macos-release runner label (#927)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:03:51 -04:00
BFlat 3f605afc81
Menu rows touch, as in native menus: long menus are a fifth shorter (#402) (#844)
Menu rows inherited the dialogs' 6 pt vertical item spacing, so each row
took 30 pt and long menus such as Layer ran far past the window on smaller
displays. Rows now touch (24 pt apart) and separators are 9 pt.

The denser layout exposed a latent gap from #315. In a window that runs
under the taskbar, egui opens a submenu downward whenever it fits the
window, so a submenu row near the bottom of a top-level menu could open
under the taskbar. A top-level menu now ends a scrolling submenu's height
above the taskbar in that case.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 22:00:09 -04:00
Brandon Thomas 1d8a32ed83 Release CI: macOS job on the org macos-release runner
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:54:51 -04:00
BFlat 5cd50d50ec
Brush size and hardness keys are remappable in Keyboard Shortcuts (#626) (#828)
[ ] and Shift+[ Shift+] are now commands (tools.decreaseBrushSize,
tools.increaseBrushSize, tools.decreaseBrushHardness,
tools.increaseBrushHardness), listed under Tools in Edit > Keyboard
Shortcuts as in Photoshop, so they can be rebound, recorded and driven
over MCP/CLI. The shell dispatches them through the shortcut table
instead of hard-coding the bracket keys.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:49:00 -04:00
BFlat 52ccb8a800
Document tab close button after the title on Windows and Linux, as in Photoshop (#619) (#832)
Photoshop's document tabs put the close button after the title on Windows
and before it on macOS. The Photoshop-style tab strip drew it before the
title everywhere. The tab and its close button are now named for
accessibility, so tests and agents can find them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:35:21 -04:00
reese 1ea96cb288
fix(automation): judge image.applyDataSet by the files it would read (#870)
Text and visibility data sets resolve no host path, so the unconditional
deny is dropped. An untrusted session's gate now checks each Pixel
Replacement path before the data set is applied.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:25:19 -04:00
Brandon Thomas 44303db546
About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#898)
* About: contributors and models tabs compiled in from contributors.json (grab bag + table, sortable, name toggle)

Help > About PhotoCraft gets tabs About · Contributors · Models, following the craftrules
standard (standards/contributors.md): contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as
static tables (nothing read at run time; the web build has them too). Each contributor line:
@username, opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last
commit. Sortable by every column; alphabetical ignores case and the leading @. The tab is the
dialog field `tab`, so agents can switch it with ui.dialog.set. New labels are translated in
all eleven catalogs.

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

* Contributors docs: craftrules is two levels up (../../craftrules)

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

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

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

* Contributors: regenerate (verified people.toml entries)

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

* i18n: credits labels for catalogs added on main since this branch

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

* Contributors: regenerate after rebase onto main

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

* Contributors: regenerate (verified people.toml entries)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:22:11 -04:00
yang aaa946b56d
Resampling reads a bounded window, however small the target (#713) (#860)
* Resampling reads a bounded window, however small the target (#713)

resize sized each band's source read from the filter support, which
grows as 1/scale, and read it in one block: shrinking a 6000x4000
image to one pixel allocated about 3 GB, and a large sparse document
could not be shrunk at all.

- Taps outside the source's content all read the default pixel, so
  their weight is folded into one 'outside' term instead of being read.
  The read window never extends past the content.
- Source rows are read in chunks under a fixed budget and go straight
  into the output rows that use them, so no band holds a full-height
  intermediate (an uneven stretch like 300000x300000 to 300000x1 stays
  bounded too).
- Tile rows with nothing stored are not read: they count as rows of the
  default pixel, so a huge sparse source costs what it holds.

Output is unchanged (within 1e-6 for surfaces with a non-zero default,
such as masks, whose empty rows are now summed rather than read);
6000x4000 to 1x1 drops from 3.2 GB / 3.3 s to
129 MB / 0.1 s, and ordinary resizes are as fast or faster.

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

* Re-run CI (the Ubuntu runner ran out of disk while compiling)

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 21:20:47 -04:00
BFlat 304322960e
Gaussian and Box Blur: large radii no longer slow down (live preview too) (#464) (#867)
Blurs run as box passes now filter the whole area at once instead of tile by tile: the
horizontal passes run over the source rows only and the vertical passes over column strips,
with the canvas edge repeated virtually instead of materialising a 3-sigma margin around every
tile (at radius 500 that margin was ~25 MP of floats per 4 MP tile, copied and transposed
several times). The results are written to the layer's tile columns in parallel.

Output matches the previous running sums (8-bit: identical except 114 of 2.1 M samples one
level apart; 16-bit: within 2/65535) and a direct-convolution oracle at every depth.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:16:38 -04:00
ljkx 735bb09a98
Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648) (#880)
* Lossy WebP: a pure-Rust VP8 encoder with quality and lossless controls (#648)

crates/codecs gains codecs/vp8: a clean-room VP8 key-frame encoder from RFC 6386
(boolean coder, 16x16 and 4x4 intra prediction with per-macroblock mode decision,
forward DCT/WHT paired with the bit-exact inverse transforms, dc/ac quantiser tables,
token coding with per-frame probability updates, skipped macroblocks). webp.rs wraps
the frame in the RIFF container (bare VP8, or VP8X with ALPH alpha and ICCP/EXIF/XMP).
EncodeOptions: webp_lossless now picks the encoder instead of erroring, webp_quality
(0-100) sets the quantiser; a lossy WebP reports a LossyCompression fidelity warning.

Export As: the WebP entry gets Quality and a Lossless switch (new tl! string in all
catalogs). CLI --quality, MCP/RPC doc_save quality and the engine's JPEG-style
quality turn a WebP lossy; without a quality WebP stays lossless as before.

Tests: tests/webp_lossy.rs decodes every file with image-webp (PSNR by quality,
sizes fall with quality and are >= 4x below lossless on noisy photos, flat frames
under 1.2 KB, alpha exact, metadata round trip, odd/tiny sizes, 16-bit input, the
16383 px limit as an error, text edges), plus unit tests for the coder, transforms,
prediction and header.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* i18n(it): Lossless (Export As WebP switch) in the Italian catalog that landed on main

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:12:38 -04:00
Elvis33LE fdd7141788
PSD: decode the merged composite only when something consumes it (#846)
Every PSD/PSB open decoded the merged image unconditionally, but a normal
layered Photoshop save carries a composite nobody reads: with layers
present and nothing behind the colour channels, the decoded planes went
straight to the drop. That composite is typically about half the file's
bytes, and for ZIP-compressed files the parse-time validation had already
inflated the same stream once - so layered ZIP opens paid the composite
twice for nothing (P18: opening a 20 MP layered PSD measures 1753 ms p50
against a 1000 ms budget).

The composite is now decoded only when a consumer exists: a flattened or
multichannel file (the composite IS the image), or extra alpha/spot
channels behind the colour ones. Layered files without extra channels
skip the decode entirely; everything a layered import shows comes from
the layer records either way.

The parse-time ZIP validation stays: deferring it would break the
corrupt-stream rejection the fuzz tests pin down, and with the decode
skipped, layered ZIP opens now inflate the composite once (validation)
instead of twice.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:10:38 -04:00
ajsongpilot 5902c878e5
fix(codecs): large PNG exports no longer lose bytes at a band's sync flush (#827)
The parallel PNG encoder deflates bands of rows separately and joins them with
sync flushes. Each band started with an output buffer half its size and resumed
the flush when the buffer filled. miniz_oxide (flate2's Rust backend) can
return from a sync flush that filled the buffer before compressing its last
lookahead bytes; the next call only drains the pending output and still
reports the flush done. The band then lost up to 258 bytes, and every band
after it decoded as garbage ("invalid literal/length code"). Detailed images of
4 MB and up, such as a 3000x3000 illustration, exported unreadable PNGs.

deflate_band now gives each band an output buffer past deflate's worst case, so
one call consumes the band and completes the flush. If that single call does
not finish, the parallel path returns None and the serial encoder is used.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:09:04 -04:00
bfenty 65ea2c973b
fix(io): artboard guide indices follow resource 1032 order (#722) (#845)
guides_in numbered the document guide list horizontal-first, but
psd_export writes resource 1032 vertical-first, so an artboard with
guides of both orientations was associated with the wrong guides.
Number vertical-first to match the resource.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:07:50 -04:00
bfenty 83cfc3d9ef
fix(algo): Path Blur clamps per-path speeds (#708) (#840)
* fix(algo): Path Blur clamps per-path speeds (#708)

filter.blurGallery.pathBlur clamps the scalar speed to 0..500, but
each entry of the documented paths array was deserialized without any
clamp. The halo takes the maximum speed and computes
Radius(v.max(0.0).ceil() as i32 + 1): a large finite speed saturates
the float-to-int cast to i32::MAX and the + 1 overflows, so the command
dies in a caught panic (and wraps to a negative halo with overflow
checks off).

Clamp each entry speed to the same 0..500 the scalar path uses, right
where the params are built.

* Path Blur test: drop an unused before-sample (a flat background can't change under blur)

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

---------

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:06:10 -04:00
Luna 53959abc9c
fix(warp): reject extreme rect params with BadParams (#808 follow-up) (#849)
Since #808, Rect::width/height return the exact extent for i32-extreme
edges instead of overflowing. That means a `rect` like
[-1e30, -1e30, 1e30, 1e30] now gets through `target()` in warp_cmds.rs,
because the only check there is `is_empty()`. The warp then runs on a
frame that is about 4e9 px on each side and fails deep inside with an
"internal error" (or could OOM before the panic guard catches anything).

Both edit.transform.warp and layer.smartObjects.warp go through
apply() -> target(). Add a size guard there: each side may be at most
MAX_WARP_SIDE (100_000 px), otherwise return BadParams with
"rect too large for warp".

Test: extreme_rect_is_bad_params_not_a_giant_warp sends the issue's rect
to both commands and asserts BadParams, then checks that a normal rect
still warps. Without the guard it fails with
"edit.transform.warp failed with an internal error".

Requested by tanish2k09 in the #808 review.

Co-authored-by: Luna <luna161@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:04:05 -04:00
Luna 1d78668b25
fix(ui-egui): GPU canvas tests no longer read a setup failure as "no GPU adapter" (#854)
canvas_16f and color_managed_canvas ran their setup commands with
.expect() inside the build_eframe closure. build_eframe calls that
closure synchronously, so the closure ran inside the catch_unwind that
is there to detect a missing adapter. A command returning Err panicked,
the panic was caught, and the test printed "skipping: no GPU adapter"
and passed.

The catch_unwind now wraps only the harness build. The setup commands
(file.new / edit.assignProfile / edit.fill, and the canvas_16f setup
list) run afterwards through harness.state_mut(), once the adapter
checks have passed, so an Err fails the test with the command name.

Manual check on a machine with a float-capable GPU: make a setup command
fail (e.g. pass a bogus profile to edit.assignProfile) and run
cargo test -p photocraft-ui-egui --test color_managed_canvas. It now
fails on "assign" instead of skipping green.

Fixes #760

Co-authored-by: Luna <luna161@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:00:43 -04:00
Simon W. Jackson 98e347be5f
monitor_profile test: find sleep on PATH (NixOS and the Nix sandbox have no /bin/sleep) (#855)
Co-authored-by: Simon W. Jackson <simon@simonwjackson.io>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:55:27 -04:00
BFlat 8f1e0d83ce
Filter previews stay inside the selection on large documents (#740) (#836)
The live on-canvas filter preview runs the real command on a downsampled
proxy of the document, but the proxy dropped the selection, so on documents
large enough to need a proxy (over ~5 MP) the preview filtered the whole
layer while OK only changed the selection. The proxy now downsamples the
selection with the pixels, so the preview is limited to the (feathered)
selection exactly like the result.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:38:29 -04:00
BFlat 00c406f589
Test: one press-drag-release gesture crosses menu titles and enters submenus (#775) (#837)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:30:18 -04:00
bfenty 1ee12eb90d
fix(engine): Camera Raw clamps the noise amounts to their range (#707) (#839)
filter.cameraRaw deserialized its params verbatim, so an out-of-range
noiseLuminance reached the denoise stage and became a guided-filter
radius of round((1 + 3 * n/100) * pixelScale). A value of 1e9 turned
six box-mean passes into unbounded work: the command never finished.
noiseColor had the same defect through its blur sigma.

raw_params now clamps the four noise fields to the documented 0..100,
matching how the sibling lens commands clamp their ranges at the same
boundary. The in-range path is untouched.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:24:42 -04:00
reese 2873afdda9
fix(layers): a locked group locks the layers inside it (#847)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:18:27 -04:00
Tanish Manku 6f39aba7f2
Preset store: a store directory that doesn't exist yet reads as empty on Windows too (#878)
load_actions treats a missing actions.json as an empty list via missing_file(), which
matches the error text ("not found", "no such file", "os error 2"). When the store
directory itself doesn't exist yet, as on a first launch, Windows reports os error 3
("The system cannot find the path specified."), which none of those match. So opening
a fresh store warned "actions.json skipped", and two engine tests failed on Windows
(actions_persist_with_the_preset_store, group_with_sampled_8_and_16_bit_tips_round_trips).

DirBackend::read now maps io::ErrorKind::NotFound (os errors 2 and 3) to the same
"{name}: not found" wording the in-memory backend uses. New test: a store directory
that doesn't exist yet opens with no warnings (fails on Windows before the fix).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:57:32 -07:00
Tanish Manku ce87c106a9
agent_tasks: wait for the MCP server task before removing the test folder (Windows os error 32) (#877)
Three agent_tasks tests failed on Windows at remove_dir_all with os error 32. The
headless session's AuthorizedWorkspace opens its roots as cap_std::fs::Dir handles,
and the server task that owns it was still alive when the test deleted the folder:
c.cancel() closes the client, but the spawned server drops the session
asynchronously. Linux deletes directories with open handles, Windows doesn't.

connect/connect_headless now return the server task, and shutdown() cancels the
client and awaits the server, so the handles are gone before the folder is removed.
Test-only change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:54:01 -07:00
Tanish Manku 3689754ef2
tiff_corpus: ask for layers explicitly (corpus job red since #825) (#876)
#825 made ExportOptions::default() write a flat TIFF (headless saves stay flat
unless they ask for layers). corpus_round_trips_through_layered_tiff exported with
the defaults, so every corpus file came back flat and the round trip failed (670
failures). Test-only change: request tiff_layers: true.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:53:56 -07:00
Tanish Manku 07174271d5
CI: never cancel main's runs; trim per-PR runner time (#800)
Main's CI was effectively unverified: with cancel-in-progress on every ref, each merge
cancelled the run of the one before. Between 09:56 and 20:34 UTC on 2026-10-07, 80 of 83
push runs on main were cancelled, and a formatting break from two PRs that were each green
alone (#663 + #700) went unnoticed until PRs updated to main started failing.

- ci.yml: cancel-in-progress only for pull_request. Main runs finish; a merge that lands
  meanwhile waits and runs next.
- ci.yml: a `platforms` job picks the test matrix. main, manual runs and PRs labelled
  `full-ci` get Linux, macOS and Windows. Other PRs get Linux only, unless they touch
  code whose behaviour differs by platform (apps/, ui-egui, text, gpu, tablet, heif,
  build.rs, Cargo.lock, ci.yml).
- ci.yml: the second Linux test run with craft-fonts covers only the crates that use the
  fonts (photocraft-text, photocraft-ui-egui), not the whole workspace again.
- freebsd.yml, windows-arm64.yml: nightly on main instead of on every push and every
  Cargo.toml change; PRs touching their own packaging still run them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:46:18 -04:00
Bart Louwers 0f16bce0c5
Add the Magnetic Lasso tool (#864)
* Add the Magnetic Lasso tool

A selection border that snaps to edges as the pointer moves, in the
Lasso (L) flyout after the Polygonal Lasso.

- photocraft-algo `magnetic`: live-wire edge tracing (Mortensen &
  Barrett 1995): Sobel strength, non-maximum suppression and gradient
  direction link costs, a shortest path inside a corridor of the
  detection width around the pointer's path, sub-pixel edge centres.
  Pixels are read lazily in 128 px blocks.
- Engine: `EdgeSource` (the active pixel layer, else the composite) and
  `select.magneticLasso`: rough points in, a border along the edges out;
  `trace: false` selects a given outline, which is how the tool commits.
- UI: click to start, move to trace (button up or down), points fasten
  by themselves (Frequency), click fastens, Alt-click straight, Alt-drag
  freehand, Backspace/Delete removes points, click the first point,
  double-click or Enter closes, Esc cancels, [ ] change the width.
  Options bar: Width, Contrast, Frequency, tablet pressure. `ui.inspect`
  reports the border in progress.
- The Lasso and Polygonal Lasso now apply the options bar's Feather;
  `select.lasso` rejects a feather outside 0..1000.
- Icon adapted from Noun Project's "Magnetic Lasso Tool" by Lil' Seal
  (CC BY 3.0, licence file and ATTRIBUTION.md row).

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

* it: translate the Magnetic Lasso strings (Italian landed after this PR was written)

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 19:42:45 -04:00
giallorosso84 7a9b591189
Italian UI translation (it) (#853)
* Update mod.rs

* Update system.rs

* Update live_tests.rs

* Update localization.md

* Italian UI translation (it)

* Register Italian fully and bring the catalog up to date

- LANGUAGES grows to 13 entries (the array length was still 12), with the native
  name Italiano.
- The system-locale test expects the code `it`, not `it-IT`.
- Italian joins the i18n coverage report, ATTRIBUTION.md and LICENSE-translations.txt.
- Translate the strings main gained since the catalog was written, and fix two
  comments copied from the Spanish catalog.

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 19:13:01 -04:00
Remidesbois b436950ae3
Layer Style: complete multi-instance effects and dialog parity (#157) (#786)
* layer style: instance-aware engine apply (layer.layerStyle.replace) with param overlays, contour presets and factory defaults

* layer style: instance-based dialog state preserves every effect; Preview checkbox gates the live preview

* layer style: Styles swatches and New Style… save the dialog pending state (style.presets.new effects param)

* layer style: Contour picker and Noise slider for shadows and glows (CPU-rendered noise; GPU falls back to the CPU for noisy effects)

* layer style: Make Default / Reset to Default per effect kind (user defaults persist with the presets)

* layer style: double-stroke band regression (manga case) and control-protocol docs for the instance-based dialog

* layer style: make Styles a top-level tab displaying preset swatches in the right panel

* i18n: translate the Layer Style preset strings in de and pt-br

The German and Brazilian Portuguese catalogs landed on main after this
branch added "Style {name} saved", "Make Default" and "Reset to
Default"; every_tl_literal_is_translated needs them.

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

* presets: move the stray BTreeMap import to the top of the module

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 19:05:15 -04:00
alexanderalber 8948511eeb
Large PSBs open with their pixels: decode planes past 2 GiB, plus 4-5 GB PSB fixtures (#375) (#782)
* PSD: decode planes up to 8 GiB, so large PSBs keep their pixels (#375)

MAX_DECODED_BYTES capped a single decode at 2 GiB. Real PSBs go past
that in one plane set: the merged image of a 30000x30000 RGB file is
2.7 GB, a 33000x33000 16-bit layer channel 2.2 GB. Opening such a file
gave a document with no layers (flattened files) or layers whose
channels were "treated as empty" (layered files). Saving hit the same
cap on the encode side and silently fell back to uncompressed data.

The cap is now 8 GiB on 64-bit targets and 2 GiB elsewhere, the budget
photocraft-codecs already uses for Limits::max_alloc. It stays a
backstop: the decoders bound their output by their input on their own
(RLE expands at most 64x, Raw needs every byte present). The Raw and
RLE output buffers are reserved with try_reserve_exact, so a machine
without that much memory gets an error instead of an abort.

generated with Claude Code

* PSD import: a channel that can't be decoded leaves its layer empty (#375)

The warning says the channel is "treated as empty", but the importer
filled a missing transparency channel with opaque alpha, so a layer
whose channels all failed became an opaque black rectangle over the
document. A channel that is present but undecodable now leaves the
whole layer empty (transparent), as the warning says.

tests/large_psb.rs: the damaged-channel case (PSD and PSB), and an
opt-in release test that opens a flattened 29000x29000 PSB whose
merged image decodes to 2.52 GB (16 s, 11.6 GB peak here).

generated with Claude Code

* Large-file fixtures: generate and check 4-5 GB PSBs (#375, #216)

photocraft-psd example big_psb writes five PSBs straight from the spec,
independent of this crate's writer and its limits: flattened and
layered, raw (4.4 and 4.8 GB files, data past 4 GiB) and RLE (small
files whose planes decode past 2 GiB, up to a 33000x33000 16-bit
layer). Every sample follows one formula, so photocraft-io example
big_psb_check opens a file through photocraft_io::import and checks
pixels without a reference; with SAVE set it saves as PSB and checks
the re-opened file.

Release build, this machine: all five open with correct pixels and
survive a PSB save and re-open (5-14 s per import). Before the 8 GiB
cap, three of the five opened empty or black.

Scorecard FILE-216-2 missing -> partial: no 2 GB PSD, 150-layer or
20 MP fixture yet, and nothing runs in CI.

generated with Claude Code

* psd: ZIP channels reserve fallibly and read in bounded chunks

A small ZIP channel declaring a huge decoded size could make
zip_decompress try a multi-GiB with_capacity/read_to_end and abort on
allocation failure. Reserve with try_reserve_exact/try_reserve, read
through take(expected) in 1 MiB chunks, and report LimitExceeded
instead. Tests: absurd declared sizes, past MAX_DECODED_BYTES, garbage.

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 18:59:39 -04:00
Carter Smith 2a2459f57a
Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot (#739)
* Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot

Like Photoshop, a file dropped on the active document's canvas is placed as an
embedded smart object layer and enters Free Transform (Esc takes the place back,
Enter makes place + transform one "Place Embedded" step; several files are placed
one after another). Dropped on the tab strip, it opens with its tab at the slot
under the pointer, shown by an insertion line while dragging. Elsewhere it opens
as before.

winit 0.30 drops carry no position, so the desktop shell reads the pointer from
the OS (apps/photocraft/src/cursor.rs: AppKit, x11rb, winsafe; no unsafe).

* Drop product names from comments, translate the place error, keep move_item panic-free

- Reword the new comments that named another product ("the reference app").
- "Couldn't place {name}: {error}" is a tl! string with entries in every
  complete catalog; `failed` now takes the finished message.
- The now-public `move_item` returns None for an out-of-range index instead of
  panicking (regression test in move_document's test).

* Tab moves as a document.move command, views follow their documents, drop the windows-sys lock bump

- document.move ({document?, to}) reorders tabs for the UI, agents and scripts; tab-slot drops and
  background opens go through it.
- sync_views keeps each view and its windows with their document by DocId, so moving or closing a
  tab never hands its view to another document.
- Restore os_pipe's windows-sys 0.52 lock entry (unrelated to this change).
- cursor::client_points: Windows pixel-to-point conversion, tested across monitors with different DPIs.
- de and pt-br strings for the place error.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 18:57:50 -04:00
midasdf 0675a253dd
M9: create vertical type from the Type tool flyout (#784)
* M9: create vertical type from the Type tool flyout

* Tool::ALL holds 47 tools now that Vertical Type and Content-Aware Move both landed

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 18:53:56 -04:00
bfenty f7be9fbe2c
fix(photocraft): reject an unparseable --control port instead of silently dropping it (#701) (#777)
* fix(photocraft): reject an unparseable --control port instead of ignoring it (#701)

The app read --control and PHOTOCRAFT_CONTROL_PORT with parse().ok(),
so a typo (78787), a missing value, or a following flag consumed as the
value left the app running with no control server and no diagnostic.
Validate both sources, name the offending value, and exit non-zero,
matching photocraft-cli serve --port. A missing --control value no
longer swallows the next argument.

* Exit with status 2 on a bad --control port instead of 0

A launcher or agent could not tell a rejected control port from a normal
quit. The collected port errors now map to exit status 2 (command-line usage
error) through control_args_exit_code, which is unit-tested.

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

---------

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:47:06 -04:00
Elvis33LE 57452bfd22
Bound layer-group nesting at import and Group Layers (stack-overflow guard) (#796)
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)

Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.

- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
  format crate already enforced) plus Document::max_group_depth(), an
  iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
  Unsupported error, mirroring what the .pcraft loader already does; the
  never-fail psd_to_document keeps working on such files by capping the
  recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
  bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
  a rejected call records no history step and leaves the document untouched).

* Enforce the group nesting cap in layer.moveTo and Artboard from Layers

Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.

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 18:46:45 -04:00
abc2cb f9479063be
Rework Camera Raw filter preview and navigation (#781)
* Add Camera Raw source-resolution preview navigation

* Keep web preview responsive and discard stale refinement errors

* Preserve Alt wheel intent throughout smoothed Camera Raw zoom

* Specify refinement result type for the wasm fallback

* Bound sparse Camera Raw proxy domains and validate detail tests

* Document Camera Raw zoom controls with native screenshots

* Drop the Camera Raw zoom screenshots; keep the guide as docs/camera-raw-zoom.md

We don't commit sizable binary assets. Removes fit.png and actual-size.png,
their LICENSE.txt and ATTRIBUTION row, and moves the controls guide next to
docs/camera-raw-histogram.md.

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

* Translate the Camera Raw full-resolution notice in de and pt-br

Main added German and Brazilian Portuguese catalogues after this branch
forked; the i18n coverage tests need the new label in both.

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

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:40:24 -04:00
alexanderalber c887ed94cc
Add the Content-Aware Move tool (#213) (#814)
* Content-Aware Move: move or extend the selected pixels, the gaps filled content-aware (#213)

New command paint.contentAwareMove {offset, mode: move|extend, structure 1..7,
color 0..10, sampleAllLayers}. The content is pasted at the offset, its colour
fitted to the new place by `color` (0 keeps it, 10 is a full seamless clone),
and an edge band whose width `structure` sets is re-synthesised from the
surroundings. In move mode the old place is then filled with
content_aware::fill, never sampling the moved content (no ghost). The
selection follows the content. One undo step; runs as a background job with
progress and cancel. Works on layer pixels, masks and channels, at any depth.

Timing (retouch_bench, release, 6016x6016, elliptical selections dragged
300 px): 250x250 move 240 ms / extend 129 ms, 500x500 0.93 s / 0.53 s,
1000x1000 2.6 s / 1.5 s.

Known limit: at low Structure the wide band comes out smeared (the PatchMatch
vote blurs a thin band between high-contrast regions); that is the shared
completion, not this command.

generated with Claude Code

* Content-Aware Move Tool in the J group (#213)

Tool::ContentAwareMove next to the Patch Tool: outside the selection a drag
draws a lasso, inside it drags the selection and runs paint.contentAwareMove
on release (as a background job). Options bar: Mode (Move/Extend),
Structure, Color, Sample All Layers. The drag shows the moving outline; a
live preview of the result is not part of this change. New strings are
translated in every catalog. Scorecard TOOL-213-4: missing -> partial
(Transform On Drop and the live preview are still open).

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:40:14 -04:00
BFlat 727daf5d67
Copy works on smart object, type and shape layers (#541) (#841)
Edit > Copy only reads, so it now takes whatever the active layer shows
(a smart object's, type layer's or shape's rendered pixels), with or
without a selection, as Photoshop does. Cut still needs a pixel layer and
says a smart object is not directly editable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:14:43 +07:00
Brandon Thomas 5c94c5e2b6
rustfmt crates/engine/tests/hdr_gamma_direction.rs (Format red on main since #801) (#842)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:09:41 -04:00
Brandon Thomas 25f211500b
i18n(ko): drop the duplicate Ambient and Warmth rows (main red since #811 + #824) (#834)
Both PRs added Korean labels for Relight's options, so ko.tsv listed each twice and
i18n::tests::bundled_catalogs_are_consistent failed on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:02:45 -04:00
jason4short a20c63521d
Help › Search: find any menu command by name (#815)
* Help › Search: find any menu command by name

A search field at the top of the Help menu (focused when the menu opens)
lists the menu commands matching what is typed, with their menu path and
shortcut: labels starting with the text first, then a word starting with
it, then anywhere in the label, then a match in the menu path only. Click a
result, arrow down to it and press Return, or press Return in the field to
run the first enabled one. Up to 15 results; the field scrolls with the
menu's rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Help search: match translated menu names as well as English

Untranslated strings ("Search menus", "No matching commands") fall back to English
through tr() until translators add them.

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 18:02:34 -04:00
jason4short 684f85e2e5
⌘⌥⌃-click on the canvas selects the topmost layer under the pointer (#813)
* ⌘⌥⌃-click on the canvas selects the topmost layer under the pointer

With any tool, ⌘⌥⌃-click runs `layer.pickAt` at the pointer (the topmost
visible layer with pixels there becomes the selected layer) without
switching to the Move tool; the press, drag and release are swallowed so the
active tool never paints or draws. It runs before the ⌃⌥ brush-resize
gesture, which the same keys would otherwise trigger.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* quick_pick: document the macOS-only gesture, report a failed pick as an error

Windows and Linux never report mac_cmd, so the gesture is a documented no-op there and
Ctrl+Alt keeps resizing the brush. Tests cover both, plus a failed pick.

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 18:02:23 -04:00
Elvis33LE ec64eb773c
Reject geometry params that would silently wrap through i64->i32 narrowing (#803)
`commands::int` returns an unbounded i64, and the geometry call sites narrowed
it with `as i32`, which truncates by multiples of 2^32: layer.translate moved
a layer 50 px for `dx = 2^32 + 50` and to a large negative offset for
`dx = 3e9`; select.rect and document.pixel read unrelated coordinates;
image.crop cropped a rectangle unrelated to the numbers passed; artboard
rects landed elsewhere. No panic, no Err - the document was silently
mutated and then autosaved, and the behaviour is identical in debug and
release, so panic_hunt cannot see it either.

- new `commands::int_i32(cmd, p, key)` narrows with a bad-params error
  instead of a wrapping cast; select.rect, document.pixel, layer.translate,
  image.crop and the artboard rect keys go through it
- image.crop builds its rectangle with saturating_add (x + w overflow)
- edit.contentAwareFill's `area` parser returns a bad-params error for
  malformed arrays (wrong length, non-numbers, non-finite) and saturating
  arithmetic for huge ones - `[1e30, 0, 1e30, 10]` overflowed the i32
  additions before (a debug-build panic, a garbage sampling window in
  release)
- filters' integer helper clamps out-of-range values like the neighbouring
  filter params instead of wrapping (`3e9` came back as a negative offset)
2026-10-07 18:02:12 -04:00
Luna 9d8a78a17e
fix(ui-egui): New Document keeps the physical size when Resolution changes (#758) (#818)
Editing Resolution in the New Document dialog only wrote the `resolution`
field. With Width/Height shown in inches, cm, mm, points or picas the
pixel width/height stayed as they were, so the displayed physical size
was recomputed from the old pixels and the document came out at a
different print size (A4 at 300 -> 150 ppi became 16.53 x 23.39 in).

The Resolution field now goes through a new `set_resolution` helper:
with a physical unit it scales width/height by new_ppi / old_ppi (so
A4 becomes 1240 x 1754 px at 150 ppi, still 8.27 x 11.69 in) and drops
the `__preset` marker; with pixels it keeps the pixel count, matching
Photoshop.

The Pixels/Inch <-> Pixels/Centimeter dropdown needs no change: it only
switches how the number is displayed, the stored resolution stays in
ppi, so width/height and the physical size are already unaffected.

Co-authored-by: Luna <luna161@users.noreply.github.com>
2026-10-07 18:01:33 -04:00
Luna 0c976f42d5
fix(geom): Rect::width/height no longer overflow on i32-extreme edges (#675) (#808)
width() and height() computed `x1 - x0` / `y1 - y0` with plain i32
subtraction. For extreme edges (e.g. an inverted i32::MAX..i32::MIN
rect, or a valid rect spanning most of the i32 range) the subtraction
overflows: debug builds panic, release builds wrap. warp.* commands
build a Rect straight from the JSON `rect` param, so an extreme value
reached this from user input.

Both now return the exact extent via `abs_diff` when the far edge is
past the near one, and 0 otherwise. The extent of any i32 pair fits in
u32, so no clamping or casting is needed. A plain `saturating_sub`
would avoid the panic but turn a wide valid rect into a width of
i32::MAX, which is wrong.

Follow-up (not in this change): warp_cmds still accepts absurd `rect`
params; it should reject them with a parameter error up front.

Co-authored-by: Luna <luna161@users.noreply.github.com>
2026-10-07 18:00:59 -04:00
bfenty a508ee9014
fix(engine): clone source_rect clamps huge mapped coordinates (#716) (#809)
Mapping::source_rect cast input-derived floats with `as i32` and then
added the bilinear apron. A large `source` (e.g. 3e9) with a non-
translation mapping saturates the cast to i32::MAX and the `+ 2`
overflows: debug panics, release wraps to an inverted rect and samples
the wrong region silently.

Clamp to the i32 range before casting and apply the apron with
saturating arithmetic, matching the sibling Rect::inflate convention
AGENTS.md prescribes for input-derived offsets. Normal-sized sources
produce bit-identical rects.

Adds unit tests: the issue's 3e9 + 45-degree repro stays ordered
(panics on main at clone_source.rs:127), a negative huge source
clamps the same way, and a normal mapping's rect is unchanged.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 18:00:21 -04:00
TiredIsa 4b3da49486
Camera Raw: read and write settings as a live PSD Smart Filter (part of #215) (#459)
* Camera Raw: read and write settings as a live PSD Smart Filter (part of #215)

- Map Camera Raw 18.4 (process 5/6) filter settings to and from PSD: custom white
  balance, Light, Presence, parametric and four point curves, HSL, Color Grading,
  sharpening, noise, grain and numeric post-crop vignette controls.
- Keep the original Photoshop descriptor; edits patch only changed fields. Unknown
  settings or versions stay an opaque filter, preserved byte for byte.
- PSD stores integer slider levels: export rounds continuous PhotoCraft values
  (hue 360 -> 0) instead of dropping the filter.
- Tests: field mapping, patching, opaque fallback, malformed input, rounding, and
  8/16/32-bit PSD -> edit -> .pcraft -> PSD with mask, filter order and undo/redo.

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

* Camera Raw PSD: use as_chunks for curve pairs (clippy 1.99)

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

* Camera Raw PSD: no product names in Rust comments, strings or test names

Only the descriptor class key itself keeps its literal name.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:59:16 -04:00
Nigel Ihza Firdausy 2b8be01d6f
Handle Stack Mode sources with no visible layers (#770)
* fix(engine): reject empty smart-object stack sources (#717)

* test(engine): cover stack mode with no visible layers (#717)

* style: format regression coverage for #717

* test(engine): reproduce hidden Stack Mode sources through commands
2026-10-07 17:57:43 -04:00
Nigel Ihza Firdausy b62664ad33
fix(ui): cancel stale warp drags when the mesh changes (#757) (#774) 2026-10-07 17:57:33 -04:00
BFlat 6b926cdecd
DX12: load the shader compiler from System32 or beside the exe, never from PATH (#712) (#817)
wgpu's default DX12 compiler (`Auto`) loads `dxcompiler.dll` by name, and the Windows DLL search
falls through to the current directory and PATH, so another program's DXC build (a Chrome or SDK
folder) was loaded and crashed shader compilation or failed device creation. The app now uses FXC
(d3dcompiler_47.dll, part of Windows) unless a dxcompiler.dll ships beside photocraft.exe, which is
loaded by its full path. WGPU_DX12_COMPILER still overrides.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:48:27 -07:00
bfenty 8ce0684759
fix(engine): view.newGuideLayout rejects absurd column/row counts (#704) (#797)
columns/rows came straight from params with no cap. layout_lines loops
once per requested line and each push_unique scans the growing guide
vector, so a count like 200000000 (well inside u32) never returned -
blocking the app, CLI, MCP and the TCP control channel.

Reject counts above 1000 with an actionable BadParams naming the
offending parameter. Photoshop's own dialog caps at 32; 1000 is a
generous ceiling whose worst case finishes instantly, so legitimate
grids are untouched.

Adds regression tests: the issue repro (200M columns, and rows)
returns an actionable error immediately, and bounded layouts at the
cap still produce their guides.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:47:42 -07:00
reese 4b80b031bc
Auto-Align Layers: the dialog no longer sends reference 0 (#674) (#795)
* fix(dialogs): Auto-Align no longer sends reference 0 (#674)

* style(xtask): rustfmt the i18n coverage test
2026-10-07 14:46:13 -07:00
bfenty 38d23b621f
fix(engine): keyboard shortcut bulk set reports in-call clashes (#719) (#799)
keyboard shortcut bulk set reports in-call clashes in conflicts instead of silently dropping one binding (#719). Supersedes #771.

Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>
2026-10-07 14:46:08 -07:00
bfenty 385e8979de
fix(algo): HDR Toning gamma follows its documented direction (#710) (#801)
The field documents "1 = neutral; lower = more contrast", but the tone
map used gamma directly as the powf exponent, so 0.5 lightened
midtones and 2.0 darkened them - the slider ran opposite to its own
contract. The crate's other two gamma controls (hdr.rs, proof_sim.rs)
already use the reciprocal exponent.

Apply 1.0 / gamma like they do. Neutral (1.0) is unchanged; every
other value now moves midtones the documented way.

Adds a regression test on a flat 50% grey: gamma 0.5 darkens the
midtone, gamma 2.0 lightens it (reversed on main: 0.71 / 0.25).

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:46:04 -07:00
bfenty 080f2c4c8d
fix(algo): gallery colour injection covers Colored Pencil and Neon Glow (#709) (#805)
uses_colours() derived from the params notation, but Colored Pencil
(paper tint from the background) and Neon Glow (foreground mixed over
background) read the colours in their pixel code without listing them.
The single-filter commands therefore skipped colour injection and both
filters always ran with the default white/black regardless of the
session colours, while filter.filterGallery honoured them.

Add the two filters to uses_colours() at the source, so the command
injection, the gallery dialog note, and smart-filter re-renders all
agree.

Adds regression tests: filter.gallery.coloredPencil and
filter.gallery.neonGlow output must change with the session
background (both identical on main).

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:45:59 -07:00
bfenty f15fb4ee6e
fix(algo): blur small sigmas instead of silently no-oping (#706) (#783)
gauss_box_radii followed Kovesi 2010 exactly, and the rounded triple
degenerates below sigma ~0.577: wl floors to 1 and all three radii
become 0, so every box pass early-returned and gauss_blur_n handed the
buffer back untouched for any sigma in [0.3, 0.577). Callers with
documented small sigmas (Camera Raw sharpenRadius 0.5 and colour noise,
HDR Toning radius 1, Shadows/Highlights mask blur, Oil Paint
cleanliness) silently did nothing in that band.

Clamp the degenerate case to one radius-1 pass - the smallest real
blur - which only engages where the ideal box widths are already at
their floor, leaving every sigma the paper handles well unchanged.

Adds the command-level regression test from the issue: cameraRaw
sharpenRadius 0.5 must change pixels (control: radius 3.0).

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:45:54 -07:00
reese 48bfaa9d0d
fix(file): open .psdt templates as new untitled documents (#807)
Templates show up in the Open dialog and open as the first free
Untitled-N without a path, so File › Save asks where to save instead of
offering the template. They still go to Open Recent. This covers every
way a file opens: File › Open and Open Recent, drops and launch
arguments, background opens, the control channel's app.open, file.openAs
and headless doc_open.
2026-10-07 14:45:49 -07:00
reese 9a9eb013db
fix(layers): double-clicking just above or below a layer name renames it (#823)
The rename hit box was only the name's glyph height (about 15 px of a 32 px row), so a double-click a few pixels off vertically opened Layer Style. The name's column now spans the row's full height; to the right of the name still opens Layer Style. Refs #651.
2026-10-07 14:45:44 -07:00
BFlat 03ffca0d54
Dialog buttons follow the OS order; Windows/Linux unsaved prompt asks Yes / No / Cancel (#627, #776) (#826)
Dialog buttons follow the OS order through one shared widgets::dialog_buttons helper (fixes #776, #627). Supersedes #802, whose reordering of the unsaved-changes and Revert prompts this carries to every dialog.

Co-authored-by: Luna <luna161@users.noreply.github.com>
2026-10-07 14:45:40 -07:00
Tanish Manku 41e9c742dc
Headless saves write a flat TIFF unless they ask for layers (#825)
Maintainer decision on #672: the app's Save As keeps TIFF layers as Photoshop does (it sets
ExportOptions.tiff_layers from its Layers option), but scripted and agent saves stay flat by
default so conversions keep predictable sizes. #672 merged with the default on, so every
headless TIFF save became layered.

- photocraft-io: ExportOptions::default() has tiff_layers: false. The desktop and web Save As
  already set it explicitly from their settings, so the app is unchanged.
- photocraft-cli: --tiff-layers on convert, run and batch (listed in the usage text).
- engine: file.saveACopy, Batch, Image Processor, the export-to-files commands and the lens
  batch take "tiffLayers": bool=false, through one SaveOpts { quality, tiff_layers }.
- automation: serve doc.save and MCP doc_save take tiffLayers (default false).
- Tests: default ExportOptions write a flat TIFF; the CLI writes flat without the flag and
  layered with it (run and convert); #672's layered-TIFF tests ask for layers explicitly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:35:31 -07:00
BFlat 15af216bd1
Follow-ups to #665, #666, #734: Photoshop's mask-only Remove Background, one-step eye sweep, no-clone Show Only (#824)
* layer.showOnly: no document clone, keep layers added during a solo, more tests

- Compute the rows from a borrow instead of cloning the whole document, and set
  visibility by path (one walk) instead of an id lookup per layer.
- Restoring keeps the current visibility of layers the snapshot doesn't know
  (added while one layer was shown alone) instead of leaving them unspecified.
- Tests: enclosing groups stay visible and a group's contents keep their own
  visibility, an undo ends the solo, one history step, no snapshot shows every
  layer, bad layer ids and no document return errors.

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

* Layers eye sweep: one history step, no skipped rows on a fast drag

- Every layer.setProps of one eye-column drag shares a coalesce key, so the
  whole sweep is one "Layer Visibility" undo step (it was one per row).
- A row counts as swept when the pointer passed it since the last frame, so
  a fast drag that jumps a row in one frame no longer skips it.
- The sweep lives in a small EyeSweep helper instead of inline in layer_row.
- layer.setProps names a visibility-only change "Layer Visibility" also when
  it carries a coalesce key.
- The harness test also checks one undo step and a single-move drag.

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

* Layers panel: hidden effects and smart filters get an empty eye box too

Matches the layer rows (and Photoshop): an effect or smart filter that is off
leaves its eye box empty instead of drawing a struck-out eye, so the whole
eye column of the Layers panel reads the same way.

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

* Remove Background: Photoshop's mask-only Quick Action, reusing Select Subject's sampler

Photoshop's Remove Background is a Properties (and contextual task bar) Quick
Action that analyses the selected layer and adds a layer mask from Select
Subject; it has no Layer menu item and no destructive variant.

- No menu path (drops the Layer › Remove Background placement); it stays
  reachable from Quick Actions, the command palette, the CLI, the control
  channel and MCP.
- Always a layer mask: drop the `output: "transparency"` bake and the `fill`
  layer (both are existing commands: Layer Mask › Apply, New Fill Layer) and
  the `radius` knob.
- Analyse the layer alone by default (`sampleAllLayers` false), as Photoshop
  does for the selected layer; true samples the composite.
- Reuse Select Subject's sampler: smartselect_cmds gains `with_doc_sampler`
  (what `with_sampler` now calls) instead of a second CompositeSampler and
  find/refine helpers.
- Promote the Background only once a subject is found.
- Tests: mask at 8/16/32-bit with untouched pixels, one history step,
  undo/redo, Background promotion, no subject, layer-alone default vs
  sampleAllLayers, disabled states and bad params, replacing a mask.

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

* Korean labels for Relight's Ambient and Warmth (i18n coverage test red on main after #735)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:25:22 +07:00
Tanish Manku e1cf321183
rustfmt crates/ui-egui/src/export_dialog.rs (Format red on main since #672 + #733) (#822)
Formatting only: #672 and #733 both changed export_dialog.rs and were each
formatted on their own branches; merged together, cargo fmt --check fails.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:22:39 -07:00
Tanish Manku b6f5df3b3f
pcraft directory saves: re-verify a rolling share of cached objects; stronger file signature (#821)
Follow-up to #752. After a directory save verifies an object, later saves trust it while its
file signature is unchanged. A file damaged in place with the same length and modification
time (bit rot, or a tool that restores timestamps) was trusted until the writer was recreated.

- The signature also carries inode, device and change time on Unix, which an in-place rewrite
  changes even when the modification time is restored.
- Each directory save re-reads and re-checks cached objects, oldest-checked first, up to
  DEFAULT_REVERIFY_BUDGET (16 MiB of compressed data; at least one object), so over enough
  saves every object is read again and a damaged one is rewritten. Repeat saves of small
  documents stay a full check; large ones pay a bounded cost per save.
  PcraftWriter::set_reverify_budget tunes it (0 = trust signatures, u64::MAX = check all).
- PcraftWriter's doc comment states what the cache trusts.
- Tests: a same-size, same-mtime tamper is repaired on the next save (fails with the budget
  at 0 on Windows, where there is no change time), and with a 1-byte budget the rolling
  re-check reaches the damaged object within one pass over the objects.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:20:36 -07:00
Elvis33LE 6ee3e4f752
Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) (#733)
* Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647)

The document's XMP packet lists the text of every type layer
(photoshop:TextLayers) and one id per placed document
(photoshop:DocumentAncestors) - data a flat web image should not carry:
14-116 KB per export on real designer PSDs, and layer text leaking into
images that show only part of the design.

- io: ExportOptions.xmp (XmpEmbed::All | None, default None) drops the
  packet from flat exports before the codecs see it. Layered saves
  (PSD, PSB, .pcraft) always keep everything; DPI, ICC and EXIF are
  unaffected. Export As, Save a Copy, the CLI, Layers/Artboards to
  Files and Batch all share this path and get the safe default.
- The Export As dialog gains a Metadata dropdown (None / All, default
  None, like the source application's Export As); the file-size
  estimate follows the choice.
- Tests: new xmp_export.rs covers the default (png/jpg/webp/tif carry
  no packet), the All opt-in and the layered always-keep;
  metadata_roundtrip_png and the stale-orientation test document the
  new contract (with All, an embedded stale orientation is still
  rewritten upright).

* Export As alone starts at Metadata: None; Save As and conversions keep the XMP

ExportSettings::default() and ExportOptions::default() also serve File > Save As, file.saveAs,
the CLI and automation, which keep the document's metadata (as Photoshop's Save As does).
Only the Export As dialog defaults to None. A layered TIFF honours the choice 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 17:19:17 -04:00
Brandon Thomas 83ace1138e
rustfmt crates/ui-egui/src/canvas.rs (Format red on main since #711) (#819)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:17:38 -04:00
Alex Gorevski 9ef9992878
fix(automation): close ambient filesystem authorization gaps (#763)
* fix(automation): block ambient filesystem bypasses

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

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

* test(automation): cover ambient path denial

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

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

* fix(automation): guard filesystem path authorization

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

* automation: judge the first non-empty prefs segment; refuse any path without data

prefs.set skips empty path segments, so `.scriptEvents.enabled` reached
script events past the section check. Preset import / plugin.install
with a whitespace path also read the filesystem.

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
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:14:03 -04:00
Alex Gorevski 1b24b5212b
Repair corrupt directory-save objects (#752)
* Fix directory save reuse of corrupt objects

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

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

* Avoid revalidating unchanged directory-save objects

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:13:09 -04:00
Alex Gorevski e9142754a4
Fix text ink bounds overflow (#750)
* Fix text ink bounds overflow

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

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

* Validate type size and tracking parameters

Reject out-of-range text metrics at type command boundaries to avoid silently empty renders.

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:55 -04:00
Matt Van Horn 4a0a8d0794
Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219) (#622)
* Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219)

The Actions list moves from UiState to the engine Session, so the panel, photocraft-cli, the control channel and MCP share one list. actions.play checks every nested step with the same authorization a top-level command gets in untrusted sessions.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:12:52 -04:00
Alex Gorevski 51a0816f99
Bound paint stroke work (#678, #679) (#751)
* Bound paint stroke work

Cap airbrush catch-up for long timestamp gaps and enforce safe brush dimensions before rendering, with a lower-level renderer guard and regression coverage.

Fixes #678

Fixes #679

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

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

* fix: align paint brush limits and airbrush catch-up

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:49 -04:00
Alex Gorevski 165e11ad6a
Fix hostile PSD pattern geometry (#743)
* Fix hostile PSD pattern geometry

Use checked rectangle extents and reject channel rectangles that differ from their parent tile, preventing panic-prone subtraction and huge zero-filled plane allocations. Bound aggregate decoded pattern data and PackBits expansion using parser limits and payload size. Add regression coverage for .pat, PSD global pattern blocks, and .abr embedded patterns.

Fixes #682
Fixes #683

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Preserve bounded PSD pattern channel placement

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:44 -04:00
Nigel Ihza Firdausy 1a052d6115
Reject overflowing slice rectangles (#768)
* fix(engine): reject overflowing slice rectangles (#715)

* test(engine): cover slice rectangle overflow (#715)

* refactor(engine): keep slice rect validation explicit (#715)

* test(engine): exercise overflowing slice commands (#715)

* fix(engine): reject invalid slice.set geometry before mutation
2026-10-07 17:12:39 -04:00
ljkx bd632e7eb3
Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 17:10:35 -04:00
Alex Gorevski 8302f57190
Fix vector pixel bounds overflow (#747)
* Fix vector pixel bounds overflow

Saturate exclusive pixel bounds at i32::MAX and cover extreme and ordinary paths with regression tests.

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

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

* Cover extreme vector path fill commands

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:10:21 -04:00
Alex Gorevski d9a0f38280
fix(raw): validate DNG default crop bounds (#745)
* fix(raw): validate DNG default crop bounds

Fixes #692

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

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

* fix(raw): fall back for invalid DNG default crops

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 17:10:03 -04:00
Tanish Manku 7080e6c56a
Korean labels for Relight's Ambient and Warmth options (#811)
korean_covers_generated_filter_options_and_gallery_names (from #604) requires a
Korean label for every generated filter option. Relight (#735) added the options
ambient and warmth; its CI ran on a base without #604's test, so main now fails
it. Adds the two catalog entries.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:04:29 -07:00
Matt Van Horn 2baa95d684
Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219) (#623)
* Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219)

Stateless queries in document pixels. The word, line and hit-test helpers move from type_tool.rs into photocraft-text, and the Type tool calls the same code.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:02:09 -04:00
jerrykimo 9e88c65d33
Support Alt-held polygon segments in the freehand lasso (#711)
* Support Alt-held polygon segments in the freehand lasso

* Translate the Alt lasso hints into Simplified Chinese

* Test mixed freehand and Alt-click lasso gestures

* Lasso: a press inside the selection still drags it; Alt lasso hints in every catalog

The Alt-polygon lasso takes over the Lasso's pointer events, so the selection drag from
#686 (press inside the ants to move the outline, Cmd-drag to float the pixels) no longer
reached the canvas. Hand those presses, and the drag they start, back to the canvas path.
Also adds the two new hint strings to every translation catalog.

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:57:21 -04:00
neko233 2cbb13a79d
fix(i18n): follow native UI languages on first launch (#661)
* fix(i18n): follow native UI languages on first launch

* i18n system tests: German and Portuguese are supported now; use Swedish as the unsupported tag

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:52:55 -04:00
reese 1353917f15
fix(retouch): Spot Healing honours Sample All Layers (#769) 2026-10-07 13:50:13 -07:00
EthanSuquet 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
Alex Gorevski 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
Alex Gorevski 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 Firdausy 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
Alex Gorevski 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
Alex Gorevski 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 Manku 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
Matt Van Horn 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
jason4short 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
Elvis33LE 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
才倾梦华V 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
yang 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 Manku 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
reese 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
yang 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
Alex Gorevski 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
EthanSuquet 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
kijoong 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
EthanSuquet 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 Gorevski 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
Alex Gorevski 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
EthanSuquet 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 Gorevski 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
jason4short 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 Gorevski 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
jason4short 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
Mawfyy 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
gangldiego 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
Armaan Sandhu 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
jason4short 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 Gorevski 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
jason4short 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
Elvis33LE 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 Gorevski 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 Thomas 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
ljkx 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 Thomas 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
Brandon Thomas 2cd470c08e
monitor_profile test: Unix only (it runs /bin/sleep and /bin/sh); fixes Windows CI after #591 (#633)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:59:59 -04:00
Gabriele Venturini b07a2e5b43
Remember desktop window and panel sizes across restarts (#587)
* Remember desktop window and panel sizes across restarts

* Window layout: discard a saved ui.ron that would crash startup

eframe restores the window geometry and egui memory before the app runs, and a
zoom factor of 0, NaN or 1e30, or a NaN/huge window size aborted every launch
(dpi/wgpu assertions). Check the file first and remove it if it holds such values.

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 09:12:44 -04:00
Cristian Moroaica b9a24da799
Apply Image onto a transparent layer no longer turns it black (#607)
The blend ran against the colour stored in transparent pixels (black),
so Multiply and the other darkening modes filled an empty layer with
opaque black. The blend now acts only where the layer has pixels, and
the alpha grows by source-over, so an empty layer takes the source in
any mode. Opaque pixels blend exactly as before.
2026-10-07 09:12:11 -04:00
Elvis33LE 09bc2ac0c4
algo: fix flaky cancel/progress test - tile workers report out of order (#609)
`apply_tiled_with` filters tiles with `chunk.par_iter()` and each worker
reports progress (`0.98 * n / total`) from its own thread. The values are
monotonic in the finish counter, but delivery order is not, and the test's
callback did a non-atomic load->assert->store of the last value: when a
worker holding a smaller value was preempted past another worker's store,
the assert fired. Observed once in a full `cargo test --workspace` run;
reproduced 1-in-300 under synthetic CPU load.

`Interrupt` never promised ordered delivery and its engine consumer
already clamps (`JobCtx::progress` keeps `fetch_max` of the f32 bits, see
crates/engine/src/jobs.rs). The test now does the same: fetch_max high-water
mark plus an in-range assert, and requires the mark to reach exactly 1.0.
Renamed to `..._progress_never_regresses` to say what it actually checks.
Stress after the fix: 0 failures in 300 runs.

The identical-looking asserts in content_aware.rs and io/tests/cancel.rs
stay strict: those paths report progress from a single thread.
2026-10-07 09:11:45 -04:00
Sirsyorrz e77325bd4a
Add fixed UI scale steps from 75% to 300% (#611)
* add 75-300% fixed ui scale steps

* UI scale: French and Indonesian labels for the new steps

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

* i18n(ko): UI scale steps

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 09:11:35 -04:00
neko233 d4086ba11a
feat(i18n): add Korean and live language switching (#582)
* feat(i18n): add Korean and live language switching

* i18n(ko): Edit Type (#598)

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 09:06:33 -04:00
Tibor-Florestan Pluto 45bedb0962
Monitor profile auto follows each window's display; what is applied is visible (#569) (#591)
* Monitor profile: say which profile is in use and why; apply a late display reading (#569)

Color Settings › Monitor Profile used to fall back to sRGB without a
word when the display's profile was missing, unreadable, not RGB or
unusable as a destination, and dropped a macOS reading that took longer
than 2 s. `ColorState::monitor_status` now reports the profile actually
applied, its fingerprint, the source (auto / manual / fallback) and the
reason for a fallback. `edit.colorSettings` (`monitorStatus`),
Help › System Info and the Color Settings dialog show it, and a fallback
in auto posts a notice.

The macOS reader also names the display it read. On a dual-display Mac
it reports the menu-bar display ("Built-in Retina Display") while the
canvas is on the external monitor, which is the display-selection bug
in #569; following the window's display is the next step.

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

* Monitor profile auto: each window uses the profile of the display it is on (#569)

The macOS reader now lists every display (id, name, frame, ICC profile
and its System Settings name) instead of asking for the "main screen",
which from a windowless helper is the menu-bar display. Each window
picks the display it overlaps most, as NSWindow.screen does, and its
canvas is shown in that display's profile: moving a window to another
display switches the profile without a restart, and two windows of one
document on different displays each keep their own. The GPU canvas
shares the document texture and keeps a display LUT per (document,
display); CPU textures are per (document, display).

Displays are read again when PhotoCraft comes back to the front (a
profile changed in System Settings or by a calibration tool), and when
a window is on an unknown display or one whose size changed (connected,
removed, rearranged), at most every 5 s. A failed re-read keeps the
previous reading. Help › System Info lists each display's profile.

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

* Monitor profile auto: act on the r1 review; no periodic re-read (#569)

Following the owner's decision on echo round r1:

- No periodic discovery: a read costs about 0.2 s of CPU, and a profile
  reassigned while PhotoCraft stays in front with no window moving
  practically doesn't happen. It is picked up at the next return to the
  front, or when Edit › Color Settings opens, which now reads at once.
- Every document window, not only the main one, asks for a reading when
  it is on an unknown display or one whose size changed.
- The focus re-read is throttled to one per 30 s; a trigger inside that
  window is deferred, not dropped.
- Starting a reading schedules the frame that applies its result, so an
  idle window doesn't wait for unrelated input.
- The osascript helper is stopped after 10 s (killed and reaped, both
  pipes drained meanwhile), so a hung reading can't block later ones.
- The Color Settings dialog recomputes "Monitor profile in use" as it
  draws, and the status says when an earlier reading is kept after a
  failed re-read (detection `retained`).

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

* Monitor profile auto: document windows count for focus and wake the main window (#569)

Echo round r2 on #591:

- Coming back to PhotoCraft through a document window (macOS reports
  focus per window, so the main window stays unfocused) now counts as a
  return to the front and reads the displays again, within the 30 s
  throttle.
- A document window that is on an unknown or resized display, or that
  brings PhotoCraft back to the front, asks the main window for a frame:
  the reading starts there, so it no longer waits for unrelated input.
  One frame per trigger, no continuous repaints.

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

* Monitor profile: pass the notice dismiss preference added on main

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 09:02:58 -04:00
才倾梦华V 3d23a708c2
Edit type layers by double-clicking their thumbnails (#598)
* Edit type layers by double-clicking their thumbnails

* Translate Edit Type for French and Indonesian

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 09:01:39 -04:00
才倾梦华V 8ebca04aa9
Add Edit Contents to Smart Object context menu (#599) 2026-10-07 09:01:20 -04:00
Sebastian Solano fa0defc56b
Add style warp splits and grid presets (#388)
* Add warp splits and grid presets

* Translate the warp grid labels

* Satisfy clippy on the warp section-edge check

* Address the warp review notes

* Format the warp handle arm

* i18n: French and Indonesian warp grid labels

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 08:40:00 -04:00
Alex Gorevski 90df9cfc15
Report per-language UI translation coverage (#220) (#427)
* feat(xtask): report UI translation coverage

Add a deterministic per-language coverage report backed by a checked English key inventory. Reject malformed, duplicate, and unknown catalog keys, and document the invocation.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(xtask): derive i18n coverage keys from source

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(xtask): handle Windows line endings in i18n scan

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Scorecard: DIST-220-4 is partial now that xtask reports translation coverage

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:54:05 -04:00
trokute 786a863f9c
i18n: "id" indonesian translation (#449)
* "id" Indonesian

* extra space for fomat (ubuntu-latest)

* Update id.tsv

* Update mod.rs

* i18n(id): translate the labels added on main since this catalog; Mixer Brush terms

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 07:52:51 -04:00
Simon Leblanc a4e96991f1
i18n: add French (fr) UI translation (#410)
* i18n: add French (fr) UI translation

A complete French catalog (menus, dialogs, panels, plurals), registered with its
plural rule (0 and 1 take the singular). Covers the latest commands, Stamp Visible
and Stamp Down included. A test checks the bundled UI fonts carry every French glyph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* i18n(fr): translate labels added on main since this PR (from #582 by neko233-com)

Co-Authored-By: neko233-com <242357072+neko233-com@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(fr): translate the labels added on main since this catalog

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

* i18n(fr): Camera Raw scopes and the Patch tool

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: neko233-com <242357072+neko233-com@users.noreply.github.com>
2026-10-07 07:49:30 -04:00
flyyangX b45dfde498
i18n: complete Simplified Chinese catalog and localize brush sections (#359)
* i18n: complete Simplified Chinese UI catalog and brush labels

* i18n(zh-hans): translate labels added on main since this PR (opening/cancel, layer navigation)

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

* i18n(zh-hans): keep main's Mixer Brush entries, translate the rendering-mode strings added since (#434)

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 07:46:52 -04:00
Sean Lavery b5eed81a25
Start context-menu parity across canvas, paths, and document tabs (#556)
* Add blend modes to Gradient tool

* Establish context menu parity contract and first workflows

* Align Select menu actions and style selection context rows

* Add Pen canvas Make Selection context action

* Complete Pen path context menu and symmetry actions

* Select menu: no extra top-level Feather (keep Select > Modify > Feather only, for 1:1 menu parity)

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

* Regenerate docs/scorecard.md and docs/parity.md

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

* Regenerate docs/scorecard.md and docs/parity.md

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 07:45:26 -04:00
Sean Lavery 8aecbffb5c
Add blend modes to Gradient tool (#543) 2026-10-07 07:43:36 -04:00
alexanderalber b4f9d56c93
Add the Patch tool with a live preview (#213) (#381)
* Add the Patch tool (#213)

paint.patch heals the selected area with texture from where the
selection is dragged (Source) or copies the selection onto the
dragged-to area (Destination), seamless-cloned like the Healing Brush.
The solve is clipped to the canvas, so patches at the edge stay opaque.

Tool::Patch joins the J group: outside the selection it draws a lasso
(Shift/Alt add/subtract), inside it drags the patch, the outline follows
the pointer and the offset is clamped to the canvas. Options bar:
Source / Destination. Strings translated in all six catalogs.

Tests at 8/16/32 bit, a UI gesture test, panic_hunt green. Scorecard
TOOL-213-3 is partial: the drag previews the outline, not the pixels.
retouch_bench: patch 250x250 22 ms, 1000x1000 362 ms (release).

generated with Claude Code

* Patch tool: live preview of the heal while dragging (#213)

The canvas shows the document healed at the pointer's offset
(patch_preview.rs, modelled on the Move tool's preview) and
recomposites only the healed area; release commits the exact
paint.patch and its damage rect refreshes the same area.

The preview (retouch_cmds::patch_preview) solves the colour fit on a
coarse grid (at most 128x128 cells) and interpolates it back: the
Poisson result is source + a harmonic correction, so the texture stays
exact and only the smooth correction is approximated (worst pixel 0.022
at 8x coarser in the test). Selection coverage is read tile-wise and
the interpolation runs per row in parallel.

retouch_bench (release, 6016x6016): preview 250x250 7 ms, 500x500
17 ms, 1000x1000 52 ms (exact patch: 21 / 90 / 354 ms). Scorecard
TOOL-213-3 done. Also applies rustfmt to the first commit's code.

generated with Claude Code

* Patch tool: cap patches at 16M pixels, drop product names from comments (#213)

The solve needs roughly 150 bytes per pixel, so the old 64M cap allowed
about 10 GB and risked an allocation abort instead of an error.

generated with Claude Code

* Regenerate docs/scorecard.md

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

* Tool::ALL holds both the Patch tool and the Mixer Brush (rebase onto #433)

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 07:42:30 -04:00
TiredIsa cc5de414dc
Camera Raw: interactive histogram, clipping warnings and scopes (part of #215) (#407)
* Camera Raw: interactive histogram, clipping warnings and scopes (part of #215)

- RGB histogram of the preview proxy (algo::histogram), drawn as RGB ribbons;
  Before/After, five draggable tonal zones, shadow/highlight clipping warnings
  (U/O) and Alt-drag channel diagnostics.
- Pixel hover readout, up to nine RGB/Lab colour samplers (S), an ICC-managed
  vectorscope (algo::vectorscope) with skin-tone line, and a floating scope panel.
- Settings regrouped as Light, Color, Effects, Curve, Color Mixer, Color Grading
  and Detail; Camera Raw labels translated through the cameraRaw context.
- One point-curve editor (point_curve.rs) shared by Camera Raw, the Curves dialog
  and adjustment-layer Properties; fixes Camera Raw curve points not dragging.
- Double-clicking a Camera Raw smart filter reopens the Camera Raw dialog with its
  settings and updates that filter, instead of the generic parameter form.
- New point curves are validated (2-16 points, increasing inputs); stored Smart
  Filters keep loading.
- camera_raw_bench for P31/P36 (not enforced until a baseline includes them).

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

* Histogram tests: as_chunks for triangle indices (clippy 1.99)

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

* Regenerate docs/scorecard.md after rebasing onto main

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

* i18n tests: close the Mixer Brush test before the Camera Raw one (rebase)

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 07:40:55 -04:00
ljkx a8afec4455
Open documents in bounded reads, with a clear out-of-memory error (#375) (#401)
Every open path read the whole file with std::fs::read, which asks
Windows for up to 4 GiB in a single ReadFile. Some file systems (exFAT,
network redirectors) reject a read that large with
ERROR_INVALID_PARAMETER, "The parameter is incorrect" (os error 87),
as reported for a 4.75 GB PSB in #375.

Add photocraft_format::read_file and read_all beside atomic_write:
64 MiB reads into a buffer reserved up front, and an OutOfMemory error
that says how much the file needed. Route every document read through
them:
- the background open job (engine jobs.rs read_path);
- the engine's read_file;
- Open Recent and OS open events;
- desktop drag and drop, which now goes through open_path (and so
  through the background job when jobs are on);
- the File > Open dialog;
- .pcraft loading;
- the automation workspace read and automation::files::open;
- smart object Replace Contents and linked files.

The dialog no longer fails silently: PickOpenFn returns the picked name
with the read result, and a failure is reported through open_failed and
the notices like any other open failure.

Speed is unchanged (1.11 s vs 1.13 s for a warm 4.75 GB file).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:40:35 -04:00
EthanSuquet fbe5f80ba4
PSD: read smart-filter masks (FEid) with one section per smart object (#396)
* PSD: read smart-filter masks (FEid) with one section per smart object

Photoshop writes the global Filter Effects block (`FEid`/`FXid`) as a
version followed by one length-prefixed section per filtered smart
object, each padded to 4 bytes outside its length. The reader took the
first section's length as the whole body, so any document with two or
more filtered smart objects failed with "filter effects: N unexpected
bytes after the data": the smart filter masks were not imported and the
open reported a warning. The 28 FEid files in the public corpora each
hold a single section, which is why the corpus never hit it.

The reader now walks every section (still accepting several items in one
section, as earlier PhotoCraft versions wrote), and the writer emits one
padded section per item, as Photoshop does.

Tests: reads_one_section_per_item (fails before: parse error) and
reads_several_items_in_one_section; malformed_data_is_an_error now cuts
only before the end of the section.

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

* PSD FEid: an empty item list still writes one section, so it reads back

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>
2026-10-07 07:40:10 -04:00
Armaan Sandhu 7dc1575efc
Content-Aware Scale: 13x faster seam removal, bit-identical output (part of #211) (#385)
* Content-Aware Scale: 13x faster seam removal, bit-identical output (part of #211)

Every removed seam recomputed the whole energy map, ran the seam search with an allocation per seam and copied the whole image into a new buffer, all on one thread: about 320 ms per seam on 24 MP. Now the image, protection and energy buffers keep their row stride and a seam is removed by shifting the rest of each row in place (in parallel); only the pixels next to the removed seam get their energy recomputed, with the same per-pixel arithmetic; the seam search reuses its buffers and its interior loop has no edge tests, so it vectorizes. Ties are broken exactly as before.

A new test pins the output bits of eight carves (shrink, enlarge, both axes, protection, 1/3/4 channels, tie-heavy noise), taken from the previous implementation.

* Content-Aware Scale: oracle test against the previous carver; keep the 1 px enlarge fix

carver_matches_reference compares the new carver bit for bit with the
implementation it replaced (now kept in the tests) over 400 random carves and
200 seam searches and energy maps. The rebase keeps main's #387 fix: a 1 px
wide image's one seam is taken so it can be duplicated.

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 07:39:49 -04:00
Armaan Sandhu 0cb27712c8
Paste: a copied image file pastes its pixels (part of #338) (#384)
* Paste: a copied image file pastes its pixels (part of #338)

Copying a file in GNOME Files, Finder or Explorer puts its path on the clipboard, not pixels, so Edit > Paste found no image and said the clipboard was empty. When the clipboard has no image, the native clipboard service now reads arboard's file list and pastes the first file that decodes (upright, as RGBA8), skipping non-images after reading only their header. TGA, which has no magic number, also needs its extension.

arboard splits text/uri-list on LF only, but the list is CRLF-terminated (RFC 2483) and GTK writes it that way, so paths copied in GNOME Files arrive with a trailing '\r'; it is trimmed before opening.

* Paste: copied files before the clipboard image; require arboard 3.6

Finder puts a copied file's icon on the clipboard as an image next to
the file URL, so reading the image first pasted the icon on macOS. The
file list is now read first and the plain image is the fallback.
file_list() needs arboard 3.5 or later, so the requirement says 3.6
(what the lockfile already has).

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

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:39:30 -04:00
Alex Gorevski bf545e0e81
Hide unconfigured layer effects from panel (#559)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8fbf5558-5421-4e91-8c33-89a347bb27da
2026-10-07 07:14:04 -04:00
sommerper 08e3e610c0
Preferences › Interface: Show bounding box when dragging layer (#484)
* Preferences › Interface: Show bounding box when dragging layer

New pref `interface.showBoundingBoxWhenDraggingLayer` (default off).
On: a Move drag shows the layer's outline and an arrow; the pixels stay
put until release. Off: the layer follows the pointer live with no
outline (Photoshop's behaviour). Label translated in all six catalogs.

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

* Keep the drag outline by default; the preference only turns off the live pixels

Off (the default) keeps today's Move drag: pixels follow the pointer and
the moved layers' outline is drawn. On, only the outline and arrow move.

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

* Regenerate docs/scorecard.md

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 07:12:58 -04:00
Alex Gorevski 047aeb747a
Wayland: explain native file drag-and-drop limitation (#386) (#430)
* Wayland: add persistent file drop guidance (#386)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix Wayland file-drop guidance review

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(app): pass no dismissal preference for portable notice

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Pass no dismissal preference for the prefs save-failure notice

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

* notices::post takes the dismiss preference: pass None for the GPU retry notice (#434)

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:12:25 -04:00
Alex Gorevski 50d52a0848
Clarify integer and float bit-depth labels (#324) (#428)
* Clarify integer and float bit-depth labels

Add type-specific labels to New Document and Image Mode with translations and focused UI/catalog tests.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix bit-depth labels and tooltips

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Keep the reference menu names for the depth commands and preference

The Image > Mode items keep their menu-catalogue labels, so the engine
labels (command palette, MCP, Keyboard Shortcuts) and the preference
choice stay "8/16/32 Bits/Channel" to match; the integer / floating point
hint comes from the new tooltips.

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: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:09:51 -04:00
Alex Gorevski 2c02d41eaf
Show active ICC profile in the status bar (#321) (#432)
* Show active ICC profile in status bar (#321)

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix active profile status review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:08:51 -04:00
Alex Gorevski d07eecf66f
PSD: read/write the artboard lock at Photoshop's lspf bit 3 (#395) (#406)
* PSD: read/write the artboard lock at Photoshop's lspf bit 3 (#395)

Photoshop writes layer locks with the artboard bit at 0x08 (a Background
layer carries 0x0D). PhotoCraft read it at 0x10, so 0x0D came back as
0x05 and the lock bytes drifted on every re-save. Map artboard to bit 3
on both read and write; still accept 0x10 on read so older PhotoCraft
files keep their lock, rewriting it at 0x08.

Co-Authored-By: Qwen 3.8 Flash <noreply@qwen.ai>

* style(io): fix PSD artboard lock review comments

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Qwen 3.8 Flash <noreply@qwen.ai>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:08:00 -04:00
Sean Lavery 395a52e7f1
Focus PhotoCraft when unsaved-close prompt appears (#547) 2026-10-07 07:07:00 -04:00
Arpit Kumar Jaiswal 45d0f4ad93
docs: sync scorecard with implemented behavior (#348)
* docs: sync scorecard with implemented behavior

* Scorecard: cite symbols instead of line numbers in the updated bug notes

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

* Regenerate docs/scorecard.md

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 07:06:21 -04:00
floflo777 6422219226
Control protocol docs: file.open and file.save are refused over --control (#391)
Two passages said they worked like app.open/app.save. Say what the policy
really refuses, and pin those cases in its test.
2026-10-07 07:05:38 -04:00
Alex Gorevski d91ab25772
Expose the Mixer Brush as a UI tool (#213) (#433)
* feat(ui): expose Mixer Brush tool (#213)

Wire the existing Mixer Brush command into the selectable UI tool model, options bar, and control-driven painting path. Cover shortcut cycling, options persistence, and selection clipping.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* docs(scorecard): mark Mixer Brush slice complete (#213)

Reflect the new Mixer Brush toolbar, options, control dispatch, and selection tests in TOOL-213-2 and regenerate the scorecard.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(i18n): translate Mixer Brush labels in zh-Hans

Add Simplified Chinese entries for the Mixer Brush tool, its options, and the brush-settings heading. Test all Mixer Brush UI strings against every registered language catalog.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Polish Mixer Brush UI controls

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Regenerate docs/scorecard.md

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: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:04:31 -04:00
siraxe bd7d47bf0f
Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for … (#453)
* Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for visibility

* Liquify Freeze Lasso: scanline fill, tests, one translated tooltip, theme colours

- apply_lasso fills node rows with an even-odd scanline pass (O(rows x edges) plus the nodes
  written) instead of a point-in-polygon test per node; non-finite points are skipped and huge
  or off-canvas polygons clamp to the field.
- LassoMask joins LiquifyTool::ALL, so the strip draws its button in the loop; its tooltip is
  one translated string (all catalogs); the outline uses theme tokens.
- A closed lasso clears Redo (#435).
- filter.liquify params doc lists lassoMask.
- Tests: concave polygon vs. a reference even-odd test at several cell sizes, thaw, degenerate
  and hostile polygons, replay; the UI lasso records a stroke, Alt thaws, undo/redo.

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

---------

Co-authored-by: JJ <jj@dot.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:49 -04:00
Alex Gorevski 48bd08150e
feat(ui): add bounded Liquify redo history (#435)
* feat(ui): add bounded Liquify redo history

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Regenerate docs/scorecard.md

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: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 06:59:03 -04:00
jason4short 5856866f89
Smart objects keep their four-corner (Distort / Perspective) placement (#426)
* Smart objects keep their four-corner (Distort / Perspective) placement

A smart object's placement was an affine transform, so a perspective
placement lost its fourth corner: a mockup PSD's distorted screen looked
right on open (Photoshop's cached pixels) but re-rendered as a
parallelogram after Edit Contents → Save.

- SmartObject gains `perspective`, the full projective map (source px →
  document px); `transform` keeps its affine approximation
- Re-renders go through it (place_source_projective), as do moves,
  canvas rotations and edit.transform, so Distort / Perspective now work
  on smart objects instead of being refused
- PSD import reads the quad (nonAffineTransform / Trnf) and export writes
  it back; .pcraft stores the field (absent for affine placements)
- Test: a distorted smart object survives Edit Contents → Save

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Smart objects: warp maps through a distorted placement; perspective round-trip tests

Warp handles and stored warps on a Distort / Perspective smart object go through its full
projective placement (smart_cmds::placement) instead of the affine approximation, so they sit
on the distorted corners. Tests: SoLd perspective round trip and quad_moved (io), a perspective
smart layer in the .pcraft rich_doc, warp on a distorted smart object (engine). Comments no
longer name another product.

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 06:58:01 -04:00
Brandon Thomas da9adf82bc
rustfmt discard_ui.rs (CI Format step red since #533) (#597)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:55:10 -04:00
floflo777 7313e08141
32-bit documents: Levels don't clip the input and output ranges (#403)
* 32-bit documents: Levels don't clip the input and output ranges

Photoshop's float Levels keep the curve going past the black and white points
and use a plain power for the gamma. rgb32 and gray32 levels.psd now match
within 0.5/255 (was 10/255).

* Raise the Photoshop oracle floor to 132

* Levels: destructive Image › Adjustments › Levels doesn't clip in 32-bit either; neutral comment wording

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 06:53:56 -04:00
reese 25d198ceb1
Free Transform: Undo steps through the transform; handles are easier to grab (#365)
* fix(transform): undo inside Free Transform steps through the transform

Undo while transforming used to undo the document's last step under the
open box. Now, as in Photoshop, Undo and Redo step back and forth through
the session's own changes: each handle drag, nudge or option edit is one
step. Toggle Last State and the History panel's rows wait until the
transform is committed or cancelled. A press on a preset warp turns it into
a custom mesh only when it grabs a point, so a miss records no step.

* fix(transform): widen the Free Transform handle grab radius

Handles now grab within 12 pt instead of 8, so a corner is easier to catch
without rotating instead. The Move tool's transform controls and snapping
use the same radius. On boxes smaller than the grab areas the nearest
handle wins, so the reference point doesn't take presses on corners.

* fix(transform): route every Undo through the open transform

The interception moves from the menu path into PhotocraftApp::run, so edit.undo and edit.redo from the control channel and MCP step through the box like the menu and shortcuts do. edit.toggleLastState is refused while the box is open instead of changing the document under it.

* chore(transform): describe behaviour without naming other products
2026-10-07 06:52:31 -04:00
reese 0b7cd68f8b
fix(type): recolour selected type from the Color and Swatches panels (#364)
With characters selected while editing type, a new foreground colour from
the Color panel, the Swatches or the Color Picker now recolours them, as in
Photoshop, within the editing session's history step. The HSB sliders now
act only on an edit: their h/s/v round trip isn't exact, so comparing
values rewrote the foreground every frame.
2026-10-07 06:52:12 -04:00
reese 68831a4336
Polygonal Lasso: double-click closes the polygon; a new polygon replaces the selection in one step (#361)
* fix(ui): close the polygonal lasso on double-click

As in Photoshop, double-clicking closes the polygon at the last point. The
second click is handled in the click dispatch, so it never starts a new
polygon, including when the first click already closed it on the first
vertex. egui reports it as a triple click when a vertex went down shortly
before, so that counts too.

* fix(ui): a new polygonal lasso replaces the selection in one step

The first click fixes the selection mode. A new-selection polygon hides the old outline while it is drawn instead of deselecting, so closing it is one history step and Esc leaves the selection untouched. Enter closes the polygon in the mode it started in.
2026-10-07 06:49:53 -04:00
Carter Smith b41442c466
Color Picker: sample colours from the image with a pipette (#508)
* Color Picker: sample colours from the image with a pipette

With the Color Picker open, the pointer over the image turns into a
pipette, as in Photoshop. A click or drag there samples the composite
colour into the picker's new colour, and OK applies it. This works
whatever tool is selected; Space and middle-drag still pan. Over the
dialog the pointer is the normal arrow.

Agents get the same: `ui.pointer` (MCP `ui_pointer`) samples into an
open picker instead of driving the tool.

* Color Picker tests: rename the eyedropper test's colour helper after the rebase

#485 added a `color` test helper in the same module.

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 06:46:25 -04:00
Alex Gorevski 6237f6e46e
Layer Style: the shadow/bevel Angle slider now moves the light (#350) (#411)
* Layer Style: the shadow/bevel Angle slider now moves the light (#350)

The Drop Shadow, Inner Shadow and Bevel & Emboss dialogs exposed an Angle
slider but no "Use Global Light" checkbox. The engine defaults
use_global_light to true, so the compositor used the document's fixed
global light angle and ignored the per-effect angle — dragging Angle did
nothing.

Add Photoshop's "Use Global Light" checkbox to those three effects,
round-trip useGlobalLight through the dialog fields, show the effective
(global light) angle when it is on, and — when it is on — drive the
document's shared light angle from the slider so the shadow follows it,
exactly as in Photoshop.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(ui): preserve edited layer-style global light

Apply a layer-style global-light angle only when it differs from the dialog's initial angle, so later unchanged effects cannot override a Bevel & Emboss edit. Use the current light angle for new global-light effects and add translations for Use Global Light.

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 06:12:23 -04:00
sommerper 9f0c049b53
Unsaved-changes prompt: (D)on't Save / (C)ancel / (S)ave keys, Tab order (#533)
The buttons show their key in parentheses and the D, C and S keys answer
the prompt (Revert prompt: (C)ancel, (R)evert). Translated labels that
don't start with the key letter get it appended ("Guardar (S)"), so the
catalogs are unchanged.

Tab / Shift+Tab move right / left through the buttons and wrap. egui's
own Tab order follows the right-to-left layout, so Tab used to move left.

Buttons from widgets::primary_button / secondary_button now draw an
accent ring while they have keyboard focus.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:12:18 -04:00
yang 3c3dd881ce
Type layers keep their name in step with their text (#483) (#501)
* Type layers keep their name in step with their text (#483)

A type layer is named after its first line when it's created, but
only the Type tool's first editing session renamed it again. Editing
the text later (clicking back into the layer, type.edit, Paste Lorem
Ipsum into it) left the old name, so a layer re-typed from the
placeholder kept saying "Lorem Ipsum".

Like Photoshop, a type layer whose name is still the one its text
gives it now follows every text edit, in the same history step. A
layer named anything else (given a name at creation or renamed later)
keeps it. Paste Lorem Ipsum no longer gives its new layer a fixed
"Lorem ipsum" name, so that layer follows its text too.

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

* Type layer names: the first non-empty line, the same in the Type tool and the engine

Review on #501: the Type tool named a new layer after its first
non-empty line, the engine after its first line, so text starting with
a blank line stopped the name following the text. layer_name now takes
the first non-empty line and commit() uses it. Also drop a product name
from a doc comment.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:12:13 -04:00
Sean Lavery eb2fb2634f
Add rendering modes and CPU fallback recovery (#434)
* Add rendering policy with legacy preference migration and safe adapter selection

* Add rendering mode preferences and GPU fallback recovery warning

* Complete CPU fallback recovery and validate rendering mode UI

* Simplify GPU recovery and reuse preference persistence

* Rendering modes: rebase onto the preferences merge/retry save path; drop docs screenshots

save_preferences now writes through write_prefs (merging other instances' changes) and
only marks the revision saved when the write succeeds, so a failed recovery choice is
still retried. Removes a leftover diff3 marker in es.tsv, the docs/images/rendering-*.png
screenshots and their ATTRIBUTION row (no binary assets in the repo), and regenerates
docs/scorecard.md for the new performance.renderingMode preference.

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 06:11:06 -04:00
BFlat 60224d3fb7
Release 0.3.0 (#577)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:10:08 +07:00
Carter Smith 06b0ad7af0
Color Picker and Color panel fields accept typed values (#485)
The Color Picker's hex field was rebuilt from the colour every frame,
so anything short of six valid digits was thrown away and it stayed at
000000. It now shows what is being typed while it has focus.

The Lab and CMYK fields applied every keystroke through clamped sRGB,
so typing "60" into L went through L = 6 first and changed a and b.
The dialog now keeps the typed Lab or CMYK values while they still
produce the colour, as it already did for HSB.

The Color panel's Hue and Saturation fields (Studio and Classic
themes) reset to 0 on black and greys; they now keep the typed values.

parse_hex no longer panics on multi-byte text.
2026-10-07 05:56:00 -04:00
Ian Swope 51d85c1170
Linux: AppImage update information and a .zsync beside it (#349) (#399)
* Linux: AppImage update information and a .zsync beside it (#349)

appimagetool now embeds
gh-releases-zsync|<owner>|<repo>|latest|photocraft-*-linux-<arch>.AppImage.zsync
(owner/repo from GITHUB_REPOSITORY, default storytold/photocraft), so
AppImageUpdate, AppImageLauncher and the like can find the next release and
fetch only the blocks that changed. With zsyncmake on the host it also writes
the .zsync, into its working directory, so the script runs it in $DIST and
warns when the file is missing.

The release job installs zsync and checks the embedded string and the .zsync
before uploading; docs/releasing.md lists the new asset.

Verified locally with packaging/linux/package.sh --skip-build --formats
appimage and zsyncmake from Ubuntu 22.04: --appimage-updateinformation prints
the string, and the .zsync's URL is the AppImage's basename.

* package.sh: absolute AppDir and appimagetool paths before running in $DIST

appimagetool now runs inside $DIST so zsyncmake writes the .zsync there,
which broke relative CARGO_TARGET_DIR / APPIMAGETOOL paths (AGENTS.md
suggests target/agent-<name>). Resolve both first, remove a stale .zsync
so a missing zsyncmake is reported, and list the .zsync and zsyncmake in
the header.

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

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:26:01 -04:00
BFlat 8a8371395b
Import warns when it keeps only the first frame or page, or a JPEG ends early (#518, #523) (#563)
Decoders now report what the decoded image doesn't show in a new
`Image::warnings` (`photocraft_codecs::DecodeWarning`), and `image_to_document`
turns them into import warnings, so they reach `photocraft-cli info` /
`convert` stderr, `doc.open` over MCP and serve, and the app's open notice
through the existing import-warnings path.

* #523: APNG (acTL frames, plus a default image outside the animation),
  animated GIF (image descriptors counted by walking the blocks), animated
  WebP and multi-page TIFF (IFDs walked without decoding; reduced-resolution
  copies and transparency masks are not pages) say "only the first of N
  frames/pages was imported". Single images stay quiet; the kept frame's
  pixels are unchanged.
* #518: a JPEG whose data runs out before its end-of-image marker (marker
  segments skipped by length, scan data by byte stuffing) still decodes
  leniently but warns "JPEG data ends early (the file is truncated or
  damaged); part of the image is missing". A JPEG that ends before any scan
  data is an error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:20:02 +07:00
BFlat d9413cec0f
FreeBSD CI: run the GPU integration tests one at a time (#576)
The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
device at once: about half of recent FreeBSD runs died within 0.05 s of a GPU test
binary starting 2-3 tests in parallel (drag_preview_canvas, color_managed_canvas,
adjust_preview_gpu), before any of them finished. Run photocraft-ui-egui's
integration tests with --test-threads=1 there; everything else is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:19:48 +07:00
BFlat 2628b0dd3d
Preferences merge with other tabs/windows and retry failed saves; web startup shows a Reload hint (#475, #476, #477) (#561)
* Preferences: saves merge with what another tab or window stored, and failed writes are retried (#475, #476)

A save now writes only the values this instance changed since it last
loaded or saved them over what storage holds (a three-way merge over the
JSON tree; arrays are leaves), so a second browser tab no longer reverts
the other's changes. The saved revision moves only when the write
succeeds: a failure is retried after 2 s, doubling to 30 s (sooner after a
newer change), with a status message on the first failure and a notice
while it keeps failing. Shared by the desktop and web builds.

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

* Web: a startup that never gets going shows what may have failed and a Reload link (#477)

When the .wasm fails to download or doesn't match the page, no Rust runs,
so the loading page itself (CSS only, no script) adds the hint after 20 s.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:07:19 +07:00
BFlat 2868220032
Formats: deep .pcraft groups reopen; zero-size PSD rejected; JPEG mattes over white; no-ICC formats get sRGB (#517, #519, #521, #522) (#555)
* PSD: reject a header with zero width or height (#519)

A 0xN header opened as an empty document and saved back as another
zero-sized PSD or .pcraft. Header::validate now requires at least 1x1
(the spec range starts at 1), and PSD export validates the header so a
zero-sized canvas is never written.

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

* .pcraft: documents with deeply nested groups load again (#517)

serde_json's 128-level recursion limit made bundles with 40 or more
nested groups unreadable after a successful save. The manifest is now
parsed with that limit disabled behind a linear JSON depth check, layers
are decoded and converted one level at a time with small stack frames,
and groups may nest up to MAX_GROUP_DEPTH (100) levels, which load and
save within a few hundred KiB of stack. Saving a deeper document fails
instead of writing a bundle that can't be opened.

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

* Export: convert non-sRGB RGB to sRGB for formats without a profile (#522)

GIF, BMP, QOI, TGA, ICO and Netpbm can't embed ICC, so a linear-light
or Display P3 document was written with values that only meant something
under the dropped profile (a linear EXR came out dark). export_flat now
converts such RGB to sRGB through photocraft-cms (perceptual, as Quick
Export does) for every photocraft_io::export route. The EXR/HDR linear
conversion shares the same banded helper.

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

* Export: composite transparency over white for formats without alpha (#521)

The CLI, MCP, Save As, Layers to Files and Layer > Export As dropped the
alpha channel when writing JPEG (or Radiance HDR), exposing the colours
stored under transparent pixels, while Export As and Quick Export
composite over white. export_flat now flattens over white too (no ink
for CMYK) and says so in its warnings.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:06:55 +07:00
BFlat 7eb3b2e072
Automation: non-object params rejected; headless sees finished jobs; serve stdio survives a bad line; MCP batch keeps results (#503, #504, #505, #506, #529) (#546)
* Command params that are not a JSON object are an error (#506)

`Session::dispatch` now rejects array, string, number and boolean params
with "invalid parameters for `<id>`: params must be a JSON object" instead
of running the command with every default; `null` still means no params.
The one check covers the control channel, `serve`, MCP (headless and
bridge) and the CLI, and batch steps fail per step. `photocraft-cli run
--new` prefixes its error with `--new`.

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

* Headless requests apply finished background jobs first (#503)

A `wait: false` job was applied to its document only by a later command
or `jobs.list`, so `doc.save`, export, `doc.inspect`, `doc.render` and
`session.list` (and their MCP tools) still read and wrote the document
without it. `Headless::sync_jobs` now runs at the top of every JSON-lines
request and before every headless MCP tool call.

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

* `serve` stdio keeps the session after a rejected request line (#505)

An over-long request line (or one that is not UTF-8) ended the stdio
session, losing every open document. It now gets one error reply, the
rest of that line is skipped without being dispatched, and the next
request is served. `read_bounded_line` reads bytes, so a cut inside a
multi-byte character is reported as too long instead of an I/O error.
TCP connections still close after an oversized line.

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

* MCP `command_batch` keeps its step results when the reply budget stops it (#504)

The batch budget charged each step's plain JSON size, but MCP sends the
batch reply as escaped text content, so a batch the budget had already
stopped could still exceed the tool-result ceiling and come back as a
bare "response exceeds" error. MCP (headless and bridge) now uses
`BatchReplyBudget::escaped`, which charges the escaped size, through the
new `Headless::batch_with_budget`; `serve` keeps the plain budget.

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

* docs: the 1 MiB request-line limit does not apply to MCP stdio (#529)

The book stated the 1 MiB request-line limit without naming a transport.
It applies to desktop and headless TCP and to the headless JSON-lines
stdio server; MCP over stdio reads its own lines and only its tool
results are size-checked. Say so in the automation security pages and in
the control protocol's transport limits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:00:36 +07:00
BFlat 8f0f95959d
Control channel: menu/dialog requests wait for their job; right-click menus at the point; MCP ui_pointer button (#510, #511, #512, #514) (#548)
* ui.inspect descriptions point to ui.menu.list for the menu tree (#510)

The control-protocol doc, the control.rs method list and the MCP
`ui_inspect` description said `ui.inspect` returns the menu tree; it
never did. They now say the menu tree comes from `ui.menu.list` (over
MCP, through `control_call`).

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

* ui.menu.invoke and ui.dialog.confirm wait for the job they start (#511)

Since filters became background jobs, invoking a no-dialog filter from
the menu or confirming a filter dialog over the control channel replied
`{job, pending: true}` before the filter was applied, unlike v0.2.0 and
unlike `engine.execute`. All three now share one helper that runs the
command with script events off and, when it started a job, defers the
reply until the job's result lands; `"wait": false` keeps the immediate
`{job, pending}` reply. The now-unused `run_automation` is gone.

The new test drives Blur More through `ui.menu.invoke` and Gaussian Blur
through its dialog's OK, with and without `wait` (it failed before).

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

* ui.pointer right-click opens its menu at the pressed point (#512)

A `ui.pointer` right-click listed the layers at the requested document
point but drew the layer menu at the canvas centre, and the Brush Preset
picker opened there too. Both now open at the point's screen position,
where a mouse right-click on it opens them. `ViewXform::active` gives the
active canvas's document-to-screen mapping as last laid out (the
construction several tests repeat); the centre stays the fallback when
the point can't be mapped.

The ui.pointer doc now lists `button`, both menus, and that top-level
modifier flags are ignored when `modifiers` is given.

The new test checks two points against the mouse path and the picker
position (it failed before).

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

* MCP ui_pointer forwards button and rejects unknown arguments (#514)

Bridge clients couldn't right-click through `ui_pointer`: it forwarded
only `events` and `modifiers` and silently dropped anything else
(`button`, a typo, a top-level `space`). It now forwards an optional
`button`, and `PointerParams` denies unknown fields, so the schema says
`additionalProperties: false` and a stray argument is an error.

The new bridge test checks the forwarded params, the rejection of
`buton` and top-level `space`, and the tool schema (it failed before).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:55:01 +07:00
BFlat 13b0d856b6
Shortcuts run in the order pressed; Shift+Delete fills on Windows; colour pickers stay put while dragging (#440, #530, #534) (#552)
* Shortcuts pressed in one frame run in the order they were pressed (#440)

Undo then Save in the same frame dropped the Undo: only the first binding in table order ran. Each key press now dispatches in event order, and a shortcut that opens a dialog, prompt, menu or inline type leaves the frame's later presses to it.

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

* Shift+Delete opens Edit › Fill on Windows instead of cutting (#530)

egui-winit reports Shift+Delete as a Cut on Windows. Outside text fields, a Cut with Shift and without Ctrl is now the Shift+Delete press again, so it fills like Shift+Backspace; Ctrl+X still cuts and text fields keep their Cut.

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

* Shape and text colour pickers stay put while dragging (#534)

The picker's width follows its RGB readouts, and egui placed the popup anew every frame: near the right edge of the window it flipped between opening right and left of the swatch, under the pointer. A swatch's picker now keeps the left edge it opened with, shifted left of the swatch when the window edge needs it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:54:31 +07:00
BFlat e30b121969
History: tool and preset names, Frame from Layers as one step, undo/redo restore the targeted layers (#513, #528, #446, #496, #495) (#551)
* Elliptical Marquee selections record "Elliptical Marquee" in History (#513)

Both marquee tools commit through `select.rect`, which named every step
"Rectangular Marquee" whatever its `ellipse` param. The step is now named
after the tool that made it; the rectangular marquee keeps its name.

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

* Preset filters record their own names in History (#528)

Blur, Blur More, Sharpen, Sharpen More, Sharpen Edges and Despeckle run
a generic algorithm with fixed parameters and recorded its name
("Gaussian Blur", "Unsharp Mask", "Surface Blur"). The presets now live
in one table with their names, used for the history step and the job
progress text; Gaussian Blur, Unsharp Mask and Surface Blur keep theirs.

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

* Vertical type command no longer claims it renders horizontally (#446)

Vertical type has rendered in columns since #199, but the params text of
`type.orientation.vertical` still said it was "rendered horizontally for
now". It now documents the same `{"layer":id?}` as Horizontal.

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

* Frame from Layers is one history step (#496)

It grouped the layers through `layer.new.groupFromLayers` (its own
"Group Layers" step) and then recorded "Frame from Layers", so one undo
left the group behind. The grouping step is now folded into the frame
step with `purge_last`, as Transform Again on a copy does; if the group
can't be framed, the grouping is undone with nothing left to redo.

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

* Undo and redo restore the layers each history state targeted (#495)

History states held only the document, so after undo or redo the active
layer stayed whatever it was: redoing New Layer left the old layer
active (a Fill then painted it), and undoing Delete Layer left the
restored layer unselected. Each state now also keeps the active and
selected layers it had when it was created (when the document was
opened, or right after the step that made it), and undo and redo target
them again where they still exist. Selecting a layer is still not a
step and doesn't change any state's target, so selecting another layer
and then undoing or redoing goes back to that state's target, as the
reference app's Step Backward does.

The History panel lists redo labels from a new `redo_labels` accessor
instead of cloning and replaying the history every frame.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:54:10 +07:00
BFlat bd1322a5e4
Recovered documents keep their recovery data until saved or closed (#444) (#549)
Launch no longer deletes recovery entries right after loading them. A
recovered document adopts the entry it came from: its next autosave
replaces it in place (atomically) and saving or closing the document
removes it, so a second crash before the next autosave loses nothing.
New documents autosave under per-launch keys, so a document id reused
from an earlier launch never overwrites a surviving entry, and sidecars
whose key doesn't match their file name are ignored.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:53:48 +07:00
BFlat faaa42db95
Gradients honour explicit transparency stops; smart filter commands accept an explicit layer target (#465, #466) (#544)
* Gradients honour explicit transparency stops with colors, a preset or the current gradient (#465)

`paint.gradient` and `gradient.fill.create` dropped `transparency` unless `stops` was
given. Explicit transparency stops now replace the gradient's own opacity stops whatever
supplies the colours. One shared parser (`transparency_param`) serves paint.gradient,
gradient.fill.create/set and the presets; malformed stops are an error, not dropped.

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

* Smart filter commands check an explicit layer target, not the active layer (#466)

`layer.smartFilter.*` with `"layer":id` were refused when the active layer was not a
smart object with filters, although they edit the named layer. Their precondition is now
checked on that layer; the active layer stays unchanged, and without `layer` (menus)
nothing changes.

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

* Gradients: reword the opacity-merge doc comment

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:21:22 +07:00
BFlat 1df68e9a5d
CLI batch: recorded-action steps, --format .ext, strict --quality, refuse --out = --in (#489, #490, #491, #492) (#545)
* CLI --quality refuses every value outside 1-100 (#491)

--quality was parsed as a u8 and clamped, so 0 and 101-255 were silently
accepted while 256 and above were rejected. Every value outside 1-100 is now
a bad --quality error, checked before any file is read or written.

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

* CLI batch: --format with a leading dot names outputs <stem>.<ext> (#490)

batch built output names from the raw --format value, so --format .jpg wrote
a..jpg. One leading dot is now dropped, as the save path and
file.automate.batch already do.

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

* CLI batch --actions takes recorded-action and droplet steps (#489)

batch read only {"command", "params"} objects, so the [id, params] pairs a
recorded action or droplet stores failed with 'missing "command"'. It now
uses the engine's action parser (automate_cmds::parse_action, split out of
parse_script), which takes [id, params], objects and bare ids, bare or
wrapped in {"steps": ...} or a droplet; {"actions": ...} still works.

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

* CLI batch refuses an --out folder that is the --in folder (#492)

Results are saved as <out>/<stem>.<ext>, so --out naming the input folder
silently replaced the originals. batch now compares the canonical folders
(relative spellings, trailing separators, symbolic links) and fails before
writing anything, naming the folder; the new --in-place flag opts in.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:21:07 +07:00
BFlat 29c280a2c6
Docs: roadmap, scorecard and README after the 2026-10-07 user-report fixes (#538)
- Roadmap (updated 2026-10-07): the M11 row notes the stricter CLI,
  batch collision reports, per-step `wait` in MCP batches and safe
  headless saves; the UI / UX row lists the keyboard-only shortcuts
  (layer navigation, number keys, hardness, step and repeat), Alt-click
  sampling, double-click for Layer Style, New from Clipboard, the
  centred window and remembered Liquify settings.
- Scorecard: UI-217-1 is partial (Fill and Liquify settings persist);
  UI-217-2's note points at the new `native_options()`. docs/scorecard.md
  regenerated; docs/parity.md is unchanged (no new catalogue items).
- README: `<subcommand> --help` in the CLI snippet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:57:23 +07:00
sommerper 4337a6227a
Ctrl+Insert copies and Shift+Insert pastes on Linux; Shift+Insert pastes images on Windows (#531)
On Linux, Ctrl+Insert / Shift+Insert become Copy / Paste on the canvas (the pixel
commands' Ctrl+C / Ctrl+V) and in text fields (Event::Copy; Shift+Insert asks
egui-winit for the clipboard text with ViewportCommand::RequestPaste).

On Windows egui-winit already maps both keys, but drops Shift+Insert when the
clipboard holds only an image. Its key release now pastes, like the existing
Ctrl+V fallback.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:05:59 +07:00
floflo777 8a5e89faa6
32-bit documents: Exposure works on the stored linear values (#400)
* 32-bit documents: Exposure works on the stored linear values

Their samples are linear already, so going through the 2.2 curve applied
the gamma twice. rgb32 and gray32 exposure.psd now match Photoshop within
0.5/255 (was 69/255 and 44/255).

* Raise the Photoshop oracle floor to 130
2026-10-07 11:49:29 +07:00
Alex Gorevski a312c3d0c7
PSD export: select PSB from size estimate (#431)
Estimate encoded raster and retained-data size with checked arithmetic, and select PSB when the PSD limit is exceeded. Keep forced PSB choices and document the completed scorecard slice.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 11:41:08 +07:00
sommerper 4fdb71dfc3
Dialogs keep their top-left when their content resizes (#487)
Dialogs were anchored at the screen centre, so a body that grew (Layer
Style switching effects) re-centred the dialog and moved its title bar.
They now open centred, then pin their top-left after the first laid-out
frame: they grow down and right, and title-bar drags move the pinned
corner (still clamped on screen).

The Color Range resize test now asserts the top-left stays put (it
failed before the fix).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:40:54 +07:00
nsn 72e2eb9644
Spanish: add missing tool, filter and path translations (#488) 2026-10-07 11:40:40 +07:00
sommerper 2051c0caf4
Move drag reveals a larger-than-canvas layer's off-canvas pixels (#526)
The live Move preview redrew only the moving layers' bounds clipped to
the canvas at drag start, then translated. For a layer larger than the
canvas, the strip sliding in from beyond the edge was never
recomposited, so the edge pixels smeared along the drag path. Keep the
bounds unclipped and clip them to the canvas after translating.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:40:23 +07:00
Coy Geek 5824e3afeb
docs: add star history chart to README (#469)
Show PhotoCraft star growth at the bottom of the README with a linked Star History chart using the date view and top-left legend.

Co-authored-by: Contributor <contributor@users.noreply.github.com>
2026-10-07 11:32:34 +07:00
Carter Smith f373fdb52f
Raise rust-version to 1.95, the minimum egui 0.36 needs (#471) 2026-10-07 11:32:23 +07:00
BFlat d38eefc8f7
Free Transform a Copy (Cmd+Alt+T); Esc takes the copy back (part of #352) (#482)
The other hidden "quick transform" shortcut. Cmd+Alt+T (no menu item,
in Keyboard Shortcuts as "Free Transform a Copy") duplicates the active
layer, or with a selection copies the selected pixels to a new layer
(Layer via Copy), and opens Free Transform on the copy. OK leaves the
original in place and the transformed copy as one history step (the
duplicate's state is folded away); an untouched OK keeps the copy where
it is; Esc, or a transform that fails, undoes the copy and leaves
nothing to redo. `TransformSession::copy` marks such a session.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:56:58 +07:00
Alex Gorevski 7b3d131bc8
docs: correct VectorCraft sibling name (#429)
Replace the stale DrawCraft name in the contributor sibling list.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 10:43:46 +07:00
Pedro Jose Soto Rivera cb00e2921b
Type: dragging a paragraph box handle resizes the box (#351) (#376)
The frame drawn around paragraph text while editing had eight handles,
but a press on them only placed the caret. They now resize the box:
corners move two edges, side handles one, with a 4 px minimum. The drag
goes through type.edit with the session's coalesce key, so it is one
history step.
2026-10-07 10:43:31 +07:00
ljkx d2576b27eb
Profile mismatch: compare profiles by colour, not bytes (#394)
Opening any file Photoshop tagged with sRGB IEC61966-2.1 asked
"Embedded Profile Mismatch: sRGB IEC61966-2.1 vs sRGB IEC61966-2.1":
open_policy compared a hash of the ICC bytes, and Photoshop's v2 profile
(1024-entry tables) and the built-in v4 sRGB (parametric curves) are the
same colours in different bytes. 32-bit documents in Photoshop's linear
sRGB missed the stay-linear exception for the same reason.

Add Profile::same_colors: same colour space and every sample of a device
grid within dE76 0.5 through both (re-encoding costs 0.025; moving the
red primary 2% costs 1.0). Colours are compared rather than round-tripped
because CMYK -> PCS -> CMYK is not an identity even for one profile.

Of the 75 profile-tagged PSDs in the test corpora, all 75 prompted on
open; now 6 do (Adobe RGB, Gray Gamma 2.2, SWOP), each a real mismatch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:43:18 +07:00
Nauman Ahmed 870741a5cd
fix: use wasm-safe timing for Liquify (#393) 2026-10-07 10:38:32 +07:00
Alex Gorevski 1cb5af9218
Build desktop app by default (#425)
Include apps/photocraft in workspace default-members so a plain cargo build also builds the native UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 10:34:59 +07:00
Armaan Sandhu 9cf7c42f1d
Content-Aware Scale: enlarging a 1 px wide or tall layer no longer panics (#387)
To enlarge, carve_width_with finds seams on a shrinking copy and duplicates them, but it stopped once the copy was 1 px wide, so a 1 px wide image found no seam and came back at its old width. The caller then wrote a short buffer into the new size (the "region data length mismatch" assertion in write_region) or, with a height change too, transpose indexed past the end. The session's guard turned both into "failed with an internal error". The single column is now taken as the seam, so it is repeated; wider images are unaffected, because a round takes at most half the columns.
2026-10-07 10:34:44 +07:00
HEOJUNFO 533566c5ad
prefs.reset resets a single colorSettings key (#445) (#467)
`prefs.get` and `prefs.set` route `colorSettings.*` to the colour state,
but `prefs.reset` only special-cased the whole section. A nested path
fell through to `Preferences::reset`, which has no colorSettings, so it
failed with "unknown preference". Take the default from
`ColorSettings::default()` and set it the way `prefs.set` does.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 10:34:30 +07:00
HEOJUNFO 778e8e187d
Image Size shows the one-pixel minimum it applies (#441) (#468)
`dim_field` allows negatives for Canvas Size's relative mode, so a typed
0 width stayed 0 in the Image Size dialog, which previewed "0 px × 1 px"
while `image.imageSize` resized to 1×1. Clamp edited dimensions to one
pixel so the preview shows the size that will be applied.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 10:34:17 +07:00
floflo777 9ae984c0ae
Use the real Gaussian Blur id in the docs and MCP schema (#390)
filter.blur.gaussian isn't registered, so the documented CLI run and the
agent loop failed with "unknown command". Pin the schema examples with a test.
2026-10-07 10:34:03 +07:00
Pedro Jose Soto Rivera a0997942d3
Export: TGA in Save As and Export As (#363) (#377)
The TGA codec already encoded and decoded, but neither Save As nor
Export As listed it. Add it to both and cover the round trip at
RGBA, RGB and gray.
2026-10-07 10:28:01 +07:00
BFlat 93bd44899b
Number keys set opacity, Shift+[ / Shift+] step hardness, Shift+Cmd+A opens Camera Raw (part of #352) (#473)
* Number keys set opacity, Shift+[ / Shift+] step hardness, Shift+Cmd+A opens Camera Raw (part of #352)

More of the shortcuts users expect without a menu item:

- 1–9 set 10–90% opacity and 0 sets 100%; two digits typed quickly
  give an exact value (4 5 = 45%, 0 0 = 0%). With the Brush, Pencil or
  Eraser that's the brush's Opacity, and Shift+digit its Flow (Brush,
  Eraser); with the Gradient or Paint Bucket their Opacity; with tools
  that have no opacity of their own (Move, selection, Type…) the active
  layer's Opacity, Shift for Fill. The locked Background, and tools
  whose number is a strength or exposure, are left alone. Digits are
  read by physical key, so Shift+digit works on any layout.
- Shift+[ / Shift+] step a painting tool's hardness by 25%. They're
  checked before [ and ], whose egui key test ignores Shift.
- Filter › Camera Raw Filter… gets its default Shift+Cmd+A.

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

* opacity_keys: module doc without a product name

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:19:04 +07:00
BFlat f697e43ef7
Transform Again on a Copy (Cmd+Alt+Shift+T): step and repeat (part of #352) (#474)
The hidden shortcut for step-and-repeat was missing. A new command,
`edit.transform.againCopy` (no menu item, Cmd+Alt+Shift+T), duplicates
the active layer and repeats the last transform on the copy, as one
history step labelled "Transform Again", so repeated presses lay out a
row (or a ring) of copies one transform apart. The transform is worked
out before anything changes (`transform_again_params`, now shared with
Edit › Transform › Again), so nothing to repeat leaves no copy; a copy
that can't be transformed (position locked) is taken back with nothing
left to redo (`History::clear_redo`).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:55:50 +07:00
BFlat f49b5a4853
Keyboard layer navigation: Alt+[ / Alt+] / Alt+, / Alt+. and Shift to add (part of #352) (#470)
The layer shortcuts with no menu item were missing. A new module,
`layer_nav_cmds`, adds six commands that walk the Layers panel's rows
(a group's row before its children, collapsed groups as one row):
Select Layer Above / Below (Alt+] / Alt+[), Select Top / Bottom Layer
(Alt+. / Alt+,), and Add Layer Above / Below to Selection
(Alt+Shift+] / Alt+Shift+[). They stop at the first and last rows,
never dirty the document, and are listed under Layer in Keyboard
Shortcuts so they can be rebound. Agents run them like any command.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:31:15 +07:00
BFlat 58bfb4ee8c
The main window opens centred on the screen (#419) (#462)
Windows places a new window that has no position by cascading it from
the top-left corner, so PhotoCraft opened at a different offset from
the corner on every launch. The window now starts centred on the main
monitor (eframe's `centered`), at the same 1440 × 900, which eframe
shrinks to the monitor first and `work_area::fit_window` still
maximizes when it doesn't fit (#315). Wayland compositors keep placing
windows themselves. The window options moved into `native_options()` so
a test can check them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:19:57 +07:00
BFlat c8e0a34792
Liquify remembers its brush settings between uses and launches (#418) (#461)
Every time Liquify opened, the brush went back to the defaults (Density
50 and so on). Its settings (tool, size, density, pressure, rate, mesh
and backdrop options) are now kept in `prefs.dialogs["filter.liquify"]`
whenever the dialog closes (OK, Cancel, Esc or the control channel) and
restored the next time it opens, also after a restart. The first use
still starts from the defaults with a brush about a tenth of the image.

`LiquifyOpts::apply` and `to_json` read and write the settings; the
control channel's setter now uses `apply` too, and a damaged saved value
is clamped or skipped, never a failure to open. The three cancel paths
share one `cancel`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:19:38 +07:00
BFlat 14207b76c8
CLI: unknown flags are usage errors, `<subcommand> --help` prints usage, usage lists --quality (#421, #423) (#460)
Any `--flag` a subcommand didn't know was recorded and ignored, so
`batch --fromat jpg` wrote the source formats and exited 0, `run
--param '{…}'` turned the JSON into a stray file name, and
`<subcommand> --help` ran the subcommand (`mcp --help` started a
server). One table, `SUBCOMMANDS`, now lists each subcommand's value
flags, bare flags and handler, replacing the shared VALUE_FLAGS list:
an unknown flag in either form (`--flag value` or `--flag=value`) is a
usage error naming it (exit 2) before any work, a value on a bare flag
is an error, and `--help` or `-h` after a subcommand prints usage and
exits 0. Every documented flag keeps working in both forms.

The usage text now lists `--quality` for `run` and `batch`, which
already honoured it, and the book's CLI page describes the stricter
parsing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:00:38 +07:00
BFlat 3c6310e559
New from Clipboard, and Paste with nothing open makes a document (#368) (#458)
File › New from Clipboard (`file.newFromClipboard`, right after New…)
opens the clipboard image as a document of its own: sized to the image,
holding it as one layer, in the pixel format it was copied in. It has
no default shortcut; one can be assigned in Keyboard Shortcuts. Edit ›
Paste (Ctrl+V) with no document open does the same, so a screenshot is
one keystroke from the start screen. Both read the OS clipboard only
when invoked, stay enabled from the clipboard service like Paste, and
are a quiet no-op when it holds no image.

Agents reach it through MCP and `serve` too: the command only uses the
session clipboard, so it joins the automation allowlist of file
commands. The menu label is in every catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:00:22 +07:00
BFlat c0a7f8e7e4
Batch runs report inputs that share an output name instead of overwriting (#420, #422) (#448)
`file.automate.batch`, `file.scripts.imageProcessor`,
`file.automate.lensCorrection`, droplets (which run batch) and
`photocraft-cli batch` named every output `<stem>.<ext>` in one folder.
When two inputs mapped to the same name (`a.png` and `a.jpg` saved as
JPEG, or the same file name in two input folders), the later save
replaced the earlier result while both were listed as written and the
run exited 0.

`engine::file_cmds::OutputClaims` remembers the outputs written in a
run, ignoring case as macOS and Windows file systems do. An input whose
output was already written is skipped before any work and reported in
`errors` (the CLI prints FAIL and exits non-zero); the first result is
kept. Runs whose names don't collide, including the same stem kept in
its own formats, are unchanged. The commands' params docs say so.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:00:05 +07:00
BFlat 6119c00962
Layers panel: double-click beside a layer's name opens Layer Style (#350) (#452)
A double-click anywhere on a Layers row started a rename. Now only a
double-click on the name renames; the rest of the row follows the
layer's kind: an adjustment or fill thumbnail opens its Properties
(Layer Content Options), a Smart Object thumbnail opens its contents
(Edit Contents), and anywhere else opens Layer Style (Blending Options).
The Background still becomes a normal layer, mask thumbnails keep their
own clicks, and a command that's disabled for the layer does nothing.
docs/ui-design.md lists the row's double-click targets.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:37:47 +07:00
BFlat b926677549
Alt-click with the Brush, Pencil, Gradient or Paint Bucket samples a colour (#417) (#450)
Holding Alt (Option) with a painting tool now works as the Eyedropper
for that press: a click sets the foreground colour and a drag keeps
sampling, without painting or recording a history step. The choice is
made when the press starts, so Alt pressed or let go mid-stroke never
switches between painting and sampling, and Control+Alt stays the
brush-resize drag. While Alt is held the cursor shows the Eyedropper's
crosshair. Agents get the same through `ui.pointer` with `alt`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:27:06 +07:00
BFlat 3443833532
Headless saves without `path` never flatten over the opened file (#416) (#447)
MCP `doc_save {}` and `serve` `doc.save {}` fell back to the document's
own path for any format, so an agent that saved its work replaced the
input PNG or JPEG with a flattened, re-encoded copy, and a `format`
override wrote PNG bytes under a `.psd` name. The desktop's File › Save
already writes back only to layered files.

The rule now lives in one place, `engine::file_cmds::saves_in_place`
(plus `extension`), shared by File › Save, the control channel's
`app.save` and the headless save. Without `path`, a save writes back
only to a PSD, PSB or .pcraft file in its own format; anything else is
an error naming `path` and the file is left unchanged. An explicit
`path`, even the opened file's own, still writes as before. The MCP
`doc_save` description and `path` doc, and docs/control-protocol.md,
state the rule.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:20:43 +07:00
BFlat 55054b5fcc
MCP command_batch and serve batch honour each step's `wait` (#415) (#439)
`command_batch` steps reuse `command_run`'s params, so its schema
advertised `wait`, but the tool mapped each step to `{command, params}`
and ran it to completion: `"wait": false` blocked on the filter and
started no job. Steps now carry `wait` (default true): headless batches
start the command through `command_start`, and bridge mode forwards it
to `engine.execute`, which already honours it. `serve`'s `batch` reads a
step's `wait` the same way. The schema doc and docs/control-protocol.md
describe the per-step `wait`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:59:57 +07:00
BFlat 7930dd0adf
serve: `methods` lists jobs.list and jobs.cancel (#414) (#438)
The headless server dispatched `jobs.list` and `jobs.cancel` but left
them out of `METHODS`, so a client discovering the server through
`methods` couldn't find the job methods `engine.execute {"wait": false}`
depends on. Add them, and name them in the rpc.rs module doc and the
`photocraft-cli serve` usage text. A test checks that the job methods
are listed and that every listed name is dispatched.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:59:39 +07:00
BFlat 2620460267
ui.set: reject unknown fields; the theme error names all five themes (#412, #413) (#437)
`ui.set` skipped keys it doesn't read and replied with success, so the
`dark` field advertised by the MCP `ui_set` tool (and any typo) changed
nothing while reporting ok. It now checks every key against
`UI_SET_FIELDS` before applying any of them and names the accepted
fields in the error. The MCP tool description, its `fields` doc, the
control.rs method list and docs/control-protocol.md list the real fields.

An unknown theme listed only studio, studioLight and classic; the error
now comes from `ThemeKind::ALL` through the new `ThemeKind::id`, so it
names pro and proMedium too, and each listed name selects its theme.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:59:14 +07:00
Brandon Thomas ba9272be7d
Alt+scroll zooms the canvas in 5% steps around the pointer (#293) (#371) (#371)
Merged with #367: the wheel also navigates while hovering the canvas scrollbars.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:47 -04:00
Brandon Thomas 0940f82d34
Alt+right-drag resizes the brush and changes its hardness (#297) (#370) (#370)
Merged with #360's right-click layer menu: Cmd/Ctrl+right-click lists layers, Alt+right-drag resizes, plain right-click keeps the brush picker / erase.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:22:35 -04:00
Brandon Thomas fbcd0c238e
Canvas scrollbars, and Preferences > Tools > Overscroll works (#300) (#367) (#367) 2026-10-06 18:22:23 -04:00
Brandon Thomas 0da7d4b0f5
Right-click on the canvas lists the layers under the pointer (#307) (#360) (#360) 2026-10-06 18:22:11 -04:00
Brandon Thomas 90a46d3f89
Stamp Visible (Cmd+Alt+Shift+E) and Stamp Down; Alt+Merge keeps the originals (#217) (#356) (#356) 2026-10-06 18:21:59 -04:00
Brandon Thomas c161eaec73
Fonts: use craft-fonts as an optional build input; move the Japanese fonts out (#353) (#353) 2026-10-06 18:21:46 -04:00
Brandon Thomas 0349b0c0f1
Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345) 2026-10-06 18:21:33 -04:00
Brandon Thomas 909efc0f6d
Background jobs in the app: progress, Cancel, open-as-job (part of #210) (#373)
Re-lands #290, which was merged into its stacked base branch (jobs/engine-210) instead of main.
Same tree as the gated #290 commit 39152db.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:40 -04:00
Brandon Thomas 3b7870bee1
Background jobs: engine job API, progress and cancel (part of #210) (#289)
* Test: retry the GPU startup sentinel check while a forked child briefly shares the lock

monitor_profile tests spawn osascript in the same binary; between fork and exec the child holds
a copy of the marker file descriptor, so flock reports WouldBlock and begin() returns Busy.

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

* Background jobs: engine job API, progress and cancel (part of #210) (#289)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:41:38 -04:00
Brandon Thomas a0d49de047
Menus stay above the taskbar; oversized windows open maximized (#315) (#343) (#343) 2026-10-06 11:30:10 -04:00
Brandon Thomas f8563856a3
Menus: no menu or submenu covers the menu bar (#319) (#337) (#337) 2026-10-06 11:29:57 -04:00
Petr Leo Compel 5a6dd79852
i18n: add Czech (cs) UI translation (#328) (#328) 2026-10-06 11:29:44 -04:00
才倾梦华V b3c44cf070
Preferences: add an Apply button (#325) (#325) 2026-10-06 11:29:06 -04:00
TiredIsa abd6f6e591
Localise the UI into Russian: a complete catalog and the missed tl! call sites (#298) (#298) 2026-10-06 11:28:52 -04:00
Mawfyy edc441a8d4
Add a Spanish catalog (menus, dialogs, panels) (#299) (#299) 2026-10-06 11:28:37 -04:00
Andrew Shen 321e986f7b
i18n: add Traditional Chinese (zh-hant) UI catalog (#302) (#302) 2026-10-06 11:28:23 -04:00
Sebastian Solano 4b76aff35d
Fix logic Show the clone crosshair only while Option is held (#301) (#301) 2026-10-06 11:28:09 -04:00
leonaci 7544b649f2
fix(ui): feed every pointer move per frame so fast strokes stay smooth (#317) (#317) 2026-10-06 11:27:54 -04:00
Brandon Thomas dd2f566daa
macOS CLI: verify the shipped CLI is Developer ID signed and notarized in the release workflow (#308) (#320) (#320) 2026-10-06 10:48:05 -04:00
Brandon Thomas ae9e1896cf
Keyboard Shortcuts: Ctrl+F records as Ctrl+F, not Ctrl+ControlLeft (#292) (#323) (#323) 2026-10-06 10:47:50 -04:00
Brandon Thomas 48e6e668b9
Layers panel: rename commits on click elsewhere, Enter or Tab; one at a time (#314) (#326) (#326) 2026-10-06 10:47:33 -04:00
Brandon Thomas b6e9ec97e5
Cmd+Enter (Ctrl+Enter) loads the path as a selection (#306) (#329) (#329) 2026-10-06 10:47:11 -04:00
Brandon Thomas c046337ddf
Move tool: Auto-Select on by default; a click picks and drags the layer under the pointer (#296) (#330) (#330) 2026-10-06 10:46:55 -04:00
JGF f091c7fbf0
Windows: Alt-click clone hint and Ctrl+N channel errors (#251 follow-up) (#336) (#336) 2026-10-06 10:46:41 -04:00
才倾梦华V 4e2a83ddbd
Fix preference usage scanning on CRLF checkouts (#331) (#331) 2026-10-06 10:46:21 -04:00
Brandon Thomas e8677ff851
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware (#333) (#333) 2026-10-06 10:09:55 -04:00
Brandon Thomas 44bf13e890
PSD export: 32-bit files open in Photoshop (HDR toning Color Mode Data, #291) (#327) (#327) 2026-10-06 09:23:20 -04:00
Brandon Thomas 7ee4bb2858
Apply EXIF orientation on open, write Orientation = 1 on export (#285) (#318) (#318) 2026-10-06 08:20:36 -04:00
Brandon Thomas eaf92dcd9d
Smart Objects and Smart Filters survive PSD save and open (#288) (#288) 2026-10-06 07:46:37 -04:00
midasdf e5e2a8f2eb
Enable dictionary segmentation for Japanese type (#250) (#250)
Co-authored-by: midasdf <205497066+midasdf@users.noreply.github.com>
2026-10-06 07:44:08 -04:00
才倾梦华V b5b5ce8394
i18n: add Simplified Chinese localization (#282)
* i18n: add Simplified Chinese translation catalog (#282)

* i18n: register the Simplified Chinese catalog (#282)

Adds the zh-hans row to LANGUAGES. It's marked partial (complete_menus: false): strings added
to main since the catalog was written fall back to English until translated.

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-06 07:43:52 -04:00
Multipad-cyber 8713e26f5a
Background layer: double-click, masks and cut behave like Photoshop (#266) (#266)
Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
2026-10-06 07:43:37 -04:00
Multipad-cyber 4c94e4d63a
Marquee: stop the selection at the canvas edge (#263) (#263)
Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
2026-10-06 07:43:21 -04:00
Cocoon-Break c34facea4d
fix: forward document index in bridge inspection (#287) (#287)
Co-authored-by: kuishou68 <kuishou68@users.noreply.github.com>
2026-10-06 07:43:03 -04:00
Brandon Thomas a96a621dee
FreeBSD release tarball and README platform lines (#284) (#284) 2026-10-05 20:54:00 -04:00
Brandon Thomas 5ada60c35b
Compositor: effect maps never block inside parallel tiles (fixes #276 deadlock) (#281) (#281) 2026-10-05 20:07:16 -04:00
Brandon Thomas 3423864502
Fast Select › Modify › Smooth and Feather (part of #211) (#280) (#280) 2026-10-05 19:59:11 -04:00
Brandon Thomas 7db46219ce
Pencil tool, Edit › Fill… dialog and "Rasterize?" prompt (#213) (#279) (#279) 2026-10-05 19:48:03 -04:00
Kentaro Tsutsumi 17d4cd0d78
Localise the UI: i18n foundation and a Japanese catalog (menus, dialogs, panels) (#169)
* Localise the UI: i18n foundation and a Japanese catalog

Adds a small i18n layer and a complete Japanese catalog for the menus, the
command palette, dialogs and panels. English stays the default and the source
language; command ids, the control protocol, the CLI and MCP are unchanged.

- i18n (crates/ui-egui/src/i18n): one TSV catalog per language, registered with
  one row in LANGUAGES. Lookups: tr/tl! (plain), tr_ctx (context), tr_id
  (command-id keyed, English fallback), trn (plurals), fmt ({name}
  placeholders). Locale tags resolve most-specific-first; macOS falls back to
  the preferred-languages list.
- New preference interface.language (auto | language code), a dropdown in
  Preferences > Interface. The engine stores a validated code and does not
  know the language list.
- Widgets (checkbox, slider_row, dropdown, buttons, tabs, icon tooltips)
  translate their labels; direct egui calls use tl!("...").
- Japanese catalog (about 2,000 entries) written from the meaning of the
  English labels; no string table was extracted from any other product.
- Tests: catalog well-formedness (duplicates, placeholders, ellipses, plural
  forms, command ids) and coverage of menus, tl! literals, blend modes and
  generated preference labels.

Status-bar messages and errors stay English. Per-language font fallback,
right-to-left layout and locale-aware number formats are not done yet.

Rebased onto main (maintainer): conflicts resolved keeping main's newer UI
(tab strips, platform shortcut labels, menu navigation, Properties layout)
with the tl!/tr wrapping. command_label/tip_label translate their label
before appending the shortcut, shortcut hints use {key} placeholders, and
strings main added since are wrapped and translated where unambiguous.
The macOS locale lookup runs /usr/bin/defaults by absolute path and
ignores failures.

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

* Menus widen to fit translated labels instead of wrapping them

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

* Workspace switcher keeps English keys so translated labels still select a workspace

The options' keys were translated too, so in Japanese the current workspace
showed as "—" and picking one never matched apply_workspace.

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

* Regenerate docs/scorecard.md for interface.language

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-05 19:40:10 -04:00
Brandon Thomas c08322b7bc
PSD manual and optical kerning: import, export, render, edit (#206) (#259) (#259) 2026-10-05 19:31:53 -04:00
Brandon Thomas 352c849139
Shortcuts: rebindable fill keys, D/X and held temporary tools (#249) (#275) (#275) 2026-10-05 18:50:47 -04:00
Brandon Thomas 1520d1c527
Selection drag previews: regression tests for #253 (visible mid-drag, 1x and 2x) (#274) (#274) 2026-10-05 18:29:06 -04:00
Brandon Thomas 65c934f374
Shift rubber band: preview the straight line before the click (#257) (#273) (#273) 2026-10-05 18:28:53 -04:00
Brandon Thomas fb297945cd
Ctrl+Alt-drag resizes the brush on the canvas (#231) (#271) (#271) 2026-10-05 18:28:40 -04:00
Brandon Thomas 6dc11904bd
Brush Settings for every brush: options-bar toggle and real preset picker (#258) (#268) (#268) 2026-10-05 18:28:27 -04:00
Brandon Thomas 315be69dd5
Portable mode: keep settings beside the exe in the Windows portable zip (#228) (#267) (#267) 2026-10-05 18:15:29 -04:00
Brandon Thomas 9a76e74c24
Menus are as tall as their items; scroll only when the window is too short (#235) (#272) (#272) 2026-10-05 18:15:16 -04:00
Brandon Thomas ea0beac9fd
New Document: a typed Width/Height reaches Create (#254) (#270) (#270) 2026-10-05 18:15:03 -04:00
TiredIsa 6266b7a96b
UI: platform shortcut notation in every label and tooltip (#251) (#262) (#262) 2026-10-05 18:14:50 -04:00
Kentaro Tsutsumi 07074634f6
Align the CJK fallback font's baseline with Latin text (#260) (#260) 2026-10-05 18:14:35 -04:00
storybored379 3c1bab7cc1
Colours: handle non-hex Unicode values without an internal error (#261) (#261)
Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
2026-10-05 18:14:20 -04:00
Brandon Thomas 433b6a7184
Windows taskbar icon: .exe icon id for the MSI shortcut + icon regression tests (#248) (#269) (#269) 2026-10-05 18:14:05 -04:00
才倾梦华V 2172d085c6
Fix Windows MSI advertised shortcut icons (#238) (#238) 2026-10-05 18:13:51 -04:00
Brandon Thomas 47f4abfed4
Stylus pressure, tilt, rotation and eraser on macOS and X11 (#255) (#255) 2026-10-05 17:47:59 -04:00
Brandon Thomas 02c07eb021
Masks: Alt-click to view mask, Cmd-click vector mask to select, vector-mask target brackets (#196) (#256) (#256) 2026-10-05 17:47:46 -04:00
Brandon Thomas 968baf639e
Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190) 2026-10-05 17:47:33 -04:00
Brandon Thomas c454af8d96
Text: deterministic font family order; hide private system families (fixes a flaky test) (#264) (#264) 2026-10-05 17:47:20 -04:00
Brandon Thomas 7c6a78b05a
GPU: crash-safe startup and device-loss fallback (#243, #4) (#252) (#252) 2026-10-05 12:05:22 -04:00
Brandon Thomas 415362df6f
Marquee: Shift squares, Alt draws from the centre, live W × H readout (#208) (#247) (#247)
Merged with #240 (stroke trail reset on drag end and start).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:05:10 -04:00
Brandon Thomas dbb8911f2a
Live gradients: Gradient tool makes editable Gradient Fill layers (on-canvas handles, stops, midpoints) + Classic mode (#180) (#197) (#197) 2026-10-05 12:04:52 -04:00
Brandon Thomas 8bac8926a3
PSD stroke effects: outlines of filled shapes, vector stroke above interior effects, joined subpaths (#233) (#233) 2026-10-05 11:48:28 -04:00
Brandon Thomas ae80df2eb4
Vertical type (tategaki): lay out, render, edit and round-trip (#199) (#246) (#246) 2026-10-05 11:48:14 -04:00
Brandon Thomas 5a5faccd4a
Free Transform: selection moves only its part of a linked mask; masked preview; target masks and channels (#205) (#242) (#242) 2026-10-05 11:26:33 -04:00
Brandon Thomas e2444ceb68
Retouch tools paint the mask/channel target; Sample All Layers for Blur/Sharpen/Smudge; premultiplied Smudge (#207) (#234) (#234) 2026-10-05 11:26:20 -04:00
Brandon Thomas 866c33f7c1
Eraser on locked layers: no lock-out from the simple lock button; Stroke Path erases to the background colour (#76) (#229) (#229) 2026-10-05 11:26:06 -04:00
Brandon Thomas 34de1a5dfc
Preferences: wire tooltips, Home screen, recent files; hide what nothing reads (#245) (#245) 2026-10-05 11:16:27 -04:00
Brandon Thomas 82270711de
Atomic saves: one crash-safe atomic_write for every document write (#230) (#230) 2026-10-05 11:16:13 -04:00
Brandon Thomas eb11e1af46
CR2: measure the colour filter row phase instead of assuming RGGB (#193) (#244) (#244) 2026-10-05 11:15:58 -04:00
Brandon Thomas 15eb3a8ba5
PSD export: write tagged blocks other readers can parse (#200) (#236) (#236) 2026-10-05 11:15:44 -04:00
Brandon Thomas f114f62179
Layout documents: fix GPU device loss, live Move tool, local damage, no per-frame work (#125, #128) (#226) (#226)
Merged with #241 (menu_bar returns the menus' right edge) and #240 (stroke trail field).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:53:15 -04:00
Brandon Thomas 0f955e6c82
Scorecard: cargo xtask perf + scorecard, budgets, checklists, nightly perf (#221) (#239) (#239) 2026-10-05 10:53:02 -04:00
Brandon Thomas 1d9d92341f
PSD effects fidelity: spread metric, emboss, gradient geometry, text gamma (113 -> 118 oracle passes) (#224) (#224) 2026-10-05 10:52:47 -04:00
Brandon Thomas 109498a746
Fix live brush preview drawing a polygon (#189) (#240) (#240) 2026-10-05 10:52:33 -04:00
Brandon Thomas 7f3d9ea68a
Packaging: Linux runtime deps + start-up library check (#201); wasm 26.3 -> 18.8 MiB (#198) (#237) (#237) 2026-10-05 10:52:20 -04:00
Brandon Thomas faf52b5657
Title bar: never draw the window title over the menus or controls (#241) (#241) 2026-10-05 10:52:06 -04:00
Brandon Thomas 5e6db41fc2
FreeBSD: serialise canvas GPU tests, CI template, RAM detection, CJK font paths (#227) (#227)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:58:58 -04:00
John Hass a502a227a9
Fix automatic UI scaling on 4K monitors (#225) (#225)
Co-authored-by: John Hass <john@basement-lab.hasshouse.com>
2026-10-05 09:58:45 -04:00
Brandon Thomas a0823c5e41
FreeBSD: build and test the workspace in a FreeBSD VM (#223) (#223) 2026-10-05 09:02:29 -04:00
shou 349dfa0e57
fix(clipboard): use the wayland clipboard for images (#222) (#222) 2026-10-05 09:02:14 -04:00
Goran Mekić 2ad42d028a
Fix tests on FreeBSD with RADV (#194) (#194)
Rebased onto the paged GPU compositor (#187): the first-frame empty submit moves into encode_inner, and the test GPU lock covers the paged compositor too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:01:58 -04:00
Brandon Thomas 4832e08aad
PSD fidelity: 16/32-bit colour modes, Lab, Levels, Exposure, Dissolve oracle (corpus 133 → 140, psd-tools 212 → 222) (#202) (#202) 2026-10-05 08:42:05 -04:00
Brandon Thomas 4b53f620ef
Tool feedback: selection-mode cursor badges, scrubby zoom, contrasting outlines, Shift lines (#188) (#188) 2026-10-05 08:01:52 -04:00
Brandon Thomas 574d0d3d53
PSD corpus: psd-tools set (pinned, verified), an oracle for every file, and the fixes it found (#195) (#195) 2026-10-05 08:01:39 -04:00
Brandon Thomas a4380b2953
Properties: uniform sections, per-kind Quick Actions, full-width controls (#155) (#192) (#192) 2026-10-05 08:01:25 -04:00
Brandon Thomas 8877a812f9
GPU compositor: page layer surfaces beyond 16384 px with a memory budget (#187) (#187)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:49:38 -04:00
Brandon Thomas b22deaa944
Layers mask thumbnails + chains; Channels/Paths temporary rows and footers (#153, #154) (#191)
* Layers rows: vector-mask thumbnail, link chains, red X on disabled masks (#153)

Each mask gets a chain (click toggles layer.layerMask.linked /
layer.vectorMask.linked) and a thumbnail; disabled masks are crossed out
and Shift-click toggles them. Vector masks render white/grey, cached and
pruned like the other row thumbnails. Thumbnails shrink on narrow rows so
the name keeps its minimum width.

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

* Channels/Paths: temporary layer mask and layer path rows, aspect thumbnails, bottom footers (#154)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:45:12 -04:00
Brandon Thomas f5c6f4bb65
UI and Type tool CJK fallback: Japanese, Simplified/Traditional Chinese and Korean system fonts, lazily loaded in locale order (#149) (#185)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:23:03 -04:00
Brandon Thomas f857b4a31f
Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91) (#183)
* Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91)

Move tool: Shift locks the drag to the dominant axis (re-evaluated with
hysteresis, so moving far enough the other way switches it); Alt at drag
start duplicates the selected layers once the pointer moves and moves the
copies, folded into one "Duplicate + Move" history step; arrow keys nudge
1/10 px (Alt duplicates first) and nudge the Free Transform box. All in a new
move_mods module with two one-line hooks in canvas::tool_event.

Free Transform: Shift on the body locks to 8 directions, Alt-click moves the
reference point, Cmd on an edge skews (Shift: along the edge, Alt: about the
centre), Cmd+Alt+Shift on a corner = perspective.

Preview (#91): the moving pixels were sampled down to 2048 px (nearest
decimation) and drawn bilinear, so a 24 MP layer previewed at 1/3
resolution. Now uploaded at full resolution (up to the device limit, box
filtered beyond), with a lazily built box-filtered mip chain for zoomed-out
views and nearest filtering at >= 200% (or Nearest Neighbor interpolation).

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

* test: clear texture deltas in transform gpu_ctx helper

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:50 -04:00
Brandon Thomas b9809ca63c
Dock polish: Layers gets the height, Character/Paragraph group, eliding tab strips, reveal the active layer (#147, #150, #151, #152) (#181)
- #147: default-sized dock groups give way (down to compact heights) so the
  filler (Layers) keeps a preferred 500 pt: 10+ rows at 1440x900, ~5 at
  1280x720 (was ~2.5 and 0). Heights the user dragged to are untouched; the
  first splitter drag pins the other groups at the heights they show.
- #150: Window > Character / Paragraph (and Type > Panels) open a new
  Character | Paragraph dock group below Properties instead of switching
  Properties; choosing the visible one again closes it; workspace presets and
  Reset close it. Saved layouts missing a group slot it below its default
  predecessor, so it never lands under Layers.
- #151: dock tab strips (Pro and Studio) fit left of the panel menu button:
  padding shrinks, then labels elide with '...', then overflowing tabs move
  into a >> chevron menu; the selected tab always stays on the strip.
- #152: when the active layer changes by any means (canvas auto-select,
  Select commands, undo, automation) the Layers panel opens its closed parent
  groups (layer.setExpanded) and scrolls the row into view if it is hidden.
- Tests: rows visible at 900/720 pt; Character keeps Properties and toggles;
  tab rects never intersect the menu button in every theme at 1x/2x and
  180-420 pt; chevron menu switches tabs; reveal expands and scrolls (and
  doesn't scroll for visible rows); undo reveals. Adjustment-editor and
  shortcut tests no longer depend on default group heights.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:37 -04:00
Nigel Ihza Firdausy d94a8f617a
fix(ui): avoid duplicate sliders for wide filter ranges (#179) 2026-10-05 07:22:22 -04:00
Nigel Ihza Firdausy 043e050237
fix(ui): pad Curves graph around endpoint handles (#177) 2026-10-05 07:22:07 -04:00
Nigel Ihza Firdausy d8cf255c47
fix(ui): sample eyedropper while dragging (#176) 2026-10-05 07:21:52 -04:00
Nigel Ihza Firdausy 107302f05c
fix(ui): rerender Liquify proxy after undo (#175) 2026-10-05 07:21:37 -04:00
Brandon Thomas 1c4cb8307c
Brushes: Tilt Scale, Brush Projection, Spacing checkbox, section locks, Mixer Wetness/Mix; Brushes grid + drag and drop; options bar through tools.setBrush (#174)
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush

Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).

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

* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields

- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
  the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
  the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
  tool options map instead of warning.

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

* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush

- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
  Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
  and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
  drag group headers to reorder, context menus to rename or delete presets and groups; all via
  brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
  edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:21:23 -04:00
Brandon Thomas 433b84fae7
Docs: where we stand and where we're going (roadmap, README, AGENTS.md) (#186)
roadmap.md: the honest parity assessment now breaks parity down by dimension with dated
measurements (PSD oracle 115/170, round trips 169/169, shortcut audit 0 failures, perf numbers,
RAW coverage, AI 0%, ~20 missing tools...), an overall estimate (feature surface ~60-70%,
pro-switchable ~25-35%), and a priority order for what's next.
README: the status note says plainly that PhotoCraft isn't yet a daily-driver Photoshop
replacement, what the biggest gaps are, and links the assessment.
AGENTS.md: agents read the assessment first when picking work and update it when numbers move.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:14:53 -04:00
Brandon Thomas 3389ba558b
Menus scroll and navigate by keyboard on small displays; Color Range dialog on the no-GPU path (#145, #138) (#168)
* Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)

Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

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

* Color Range: test the dialog in the full app without a GPU; show preview failures (#145)

The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:08:25 -04:00
Brandon Thomas 10203e7a60
AGENTS.md: no Tauri, Electron or webview shells (#184)
The desktop app is native egui/eframe on wgpu and the web build is the same Rust compiled to
WebAssembly; state explicitly that Tauri (or any webview/JS UI framework) must never be added.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:41:36 -04:00
Brandon Thomas 20783fdc70
Roadmap: honest parity assessment (2026-10-05) (#182)
parity.md's 625/625 measures menu wiring, not behaviour. Record a candid assessment after
0.2.0 and the first user feedback (feature surface ~55-70%, pro-switchable ~20-35%, area by area),
how to make parity measurable (workflow acceptance tests, real-file corpus, visual QA, user
reports), mark parity-estimate.md's ~82% as an upper bound, and update the current focus.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:20:57 -04:00
Brandon Thomas 231bf5fe81
Flatpak single-file bundles as release artifacts (#163)
* Flatpak single-file bundle as a release artifact (#140)

packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml repackages the
release's Linux tarball (no Rust build, no network) on the freedesktop 26.08
runtime; packaging/linux/flatpak-bundle.sh builds it with flatpak-builder and
flatpak build-bundle and smoke-tests it. release.yml gains a Flatpak job per
arch that feeds the draft release and SHA256SUMS. The Flathub manifest moves to
26.08 with the same documented finish-args (portals + Pictures/Documents), and
packaging-lint checks the two manifests agree. Font scanning also looks in
/run/host/fonts and /run/host/user-fonts so host fonts show up in the sandbox.

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

* Docs: installing the Flatpak bundle (#140)

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

* Flatpak job: install the SVG pixbuf loader for appstream compose

Without librsvg2-common (absent on the x86_64 runner image) the host's
appstreamcli compose fails with file-read-error on the scalable icon.

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

* release.yml: the Flatpak job runs without the release environment

It only repackages the Linux job's tarball and signs nothing, so it doesn't need (and
shouldn't hold) the signing secrets while running third-party tooling.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:15:21 -04:00
Brandon Thomas 4195f25f27
io tests: PSD type-layer bounds regression (#148) (#165)
Hand-built TySh/EngineData type layers as Photoshop writes them (point,
paragraph BoxBounds, scaled and rotated transforms, multi-line with explicit
leading, mixed sizes, 72/144/300 dpi) go through PSD export -> import and must
keep their full pixel extent and layer bounds, parse to the right pt size,
transform and shape, re-render with our engine onto the same pixels (bounds
within 1 px, IoU > 0.97) and composite to the file's merged image.

Investigation of #148: imported text bounds were already right. In the
designer QA PSD, 'Headline 105' carries a 300x200 vector mask added by the
generator (every i % 10 == 5 leaf), which clips the text at x = 685; with the
mask deleted the full line renders. H 29 px is the ink height of a 36 px line
without descenders, as Photoshop reports it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:15:08 -04:00
Nigel Ihza Firdausy 6293f51954
fix(ui): make tall menus scrollable on short displays (#160) 2026-10-05 04:14:53 -04:00
kevin a202171891
fix(geom): saturate Rect::translate and inflate on i32 overflow (#159)
* fix(geom): saturate Rect::translate and inflate on i32 overflow

from_xywh already uses saturating_add. translate and inflate used
plain +/- and panicked in debug (or wrapped in release) when a layer
near the i32 edges was nudged off-canvas.

* fmt: Rect::translate/inflate at the workspace width (#159)

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-05 04:14:40 -04:00
Brandon Thomas a6c5fa6732
Layers rows: name truncates before the fx/lock/link indicators; fx triangle; Collapse All Groups (#144, #143) (#167)
- Layers panel rows lay out their right-hand indicators first (lock, fx badge
  with an effects disclosure triangle, link, optional blend-mode label) and
  give the name the remaining width, cut with an ellipsis. The Studio kind
  sub-label and the Effects sub-rows truncate the same way; a narrow panel
  squeezes group indentation before the name. Rows leave room for the
  overlay scrollbar. Drawn rects are recorded for tests (layer_row_ui).
- New view-state command layer.setEffectsExpanded (DocState::fx_collapsed,
  no history step), driven by the fx triangle (Alt-click: all layers).
- Layers panel menu: Collapse All Groups (layer.setExpanded all=false).
  Group open/closed state is document data (PSD lsct, .pcraft).
- ui.set {dockWidth} sets the right dock width (250..520) for agents and
  snapshots.
- engine example designer_psd: ~140-layer designer PSD (4-deep groups, long,
  CJK/Greek/accented names, fx, clipping, masks, vector masks, locks, links,
  type/shape/fill/adjustment layers, hidden layers) for visual QA.
- Tests: name never intersects an indicator at 1x/1.5x/2x, 250/290/520 pt and
  in every theme; fx triangle hides/shows effect rows without selecting;
  Collapse All Groups from the panel menu, state survives PSD and .pcraft.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:38:47 -04:00
Brandon Thomas 177620a58d
Layers groups expand/collapse (#126); every displayed shortcut fires (#127) (#141)
* Layers panel: group disclosure triangles expand/collapse groups (#126)

Group rows get a disclosure triangle; click toggles the group, Alt-click sets
every group. Rows now list a group directly above its contents (the panel
used to reverse a pre-order walk, putting headers below their children) and
closed groups hide their contents (a kind filter still shows them).

The state is Group::expanded (PSD section divider open/closed folder,
.pcraft), toggled by the new layer.setExpanded command as a view change:
no history step, a clean document stays clean. document.inspect reports it.

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

* Shortcuts: every displayed shortcut fires, from wherever focus is (#127)

- The menus show the catalogue's Photoshop shortcuts, but only command and
  shell shortcuts were bound: Image Size (Cmd+Alt+I), Canvas Size
  (Cmd+Alt+C), Feather (Shift+F6), Layer > Quick Export / Export As,
  Window > Info (F8) and Actions (Alt+F9) showed a key that did nothing.
  The binding table (new shortcut_dispatch module) now merges the
  catalogue's shortcuts for live items, one owner per key.
- Any focused widget swallowed every shortcut (egui_wants_keyboard_input):
  after clicking into a value field (Layers Opacity/Fill, options bar
  Opacity/Flow/Smoothing), the Curves graph, or Tab, nothing fired until
  the canvas was clicked. Now a focused text field keeps typing and its
  editing keys (select all, clipboard, undo, navigation) while Cmd
  shortcuts and F-keys still fire, as in Photoshop; a focused non-text
  widget keeps only its navigation keys.
- A shortcut whose command is disabled says why on the status bar.
- Layers panel rows carry their layer name for accessibility (and tests).

Tests press every displayed menu / shell shortcut and every tool letter in
a whole-app kittest harness with a layered document (group, type layer,
selection) from four focus places, asserting each dispatches its item or
the item is greyed with a reason; plus real runs of the common ones.

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

* UI tests: scroll Layers rows into view under the fixed-height dock

The dock (#139) gives the Layers panel a fixed height that scrolls, so a
group's triangle or a row can sit below the fold. Tests now scroll the
node into view (accesskit ScrollIntoView) and click its real rect.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:28:57 -04:00
Brandon Thomas 3c54849ba6
Type tool: caret/selection land on the glyph under the pointer; font size is live, one undo step, ~60x faster (#123, #124) (#142)
* Canvas: tool gestures start at the press origin, not where egui recognised the drag (#123)

A drag is only recognised once the pointer has moved past egui's click
distance; the Down event used the pointer position at that moment, so a
Type tool drag-selection's anchor landed a glyph or more away from where
the button went down. Adds a text-engine test that the caret geometry
(clusters, lines) sits on the rendered glyphs for tracking, horizontal
scale, mixed sizes and wrapped, centred paragraph text.

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

* Type tool: PSD type edits on the layout it shows; size edits are live, one step per drag, sized as shown (#123, #124)

- Clicking into a PSD type layer whose pixels are still Photoshop's
  re-renders it with our engine first (inside the edit session's history
  step; Cancel restores Photoshop's pixels), so caret and selection sit on
  what is shown. Hit-testing a type layer also accepts its shown pixels.
- type.edit / type.setStyle damage only the layer's old and new pixels, so
  the canvas recomposites that area instead of the whole document.
- Scrubbing a size (or any) type field coalesces per drag: live preview
  every frame, one history step per drag.
- Options bar and Properties show and edit font size and leading as the
  layer appears (size x transform scale, like Photoshop), and the options
  bar shows the size at the selection.
- examples/type_bench: 6000x4000, 50 type layers, size step went from
  297 ms to 4.6 ms (CPU canvas), 489 ms to 10 ms with drop shadows.

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

* Crop test: the frame starts at the press origin now (#123)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:54:15 -04:00
MikeeI 28a63fe27e
fix(io): recognize reordered PhotoCraft archives (#137) 2026-10-05 01:37:25 -04:00
MikeeI 7862267069
fix(engine): preserve redo on unchanged bit depth (#133) 2026-10-05 01:37:12 -04:00
Brandon Thomas 800f264ee3
Brush Settings panel: Photoshop structure and depth, edits through tools.setBrush (#136)
* tools.setBrush: skip untouched tip/pattern bitmaps; enum variant changes replace

merge_brush no longer base64-round-trips sampled tips and pattern tiles the
patch doesn't name: a Brush Settings edit on a 2500 px imported tip goes from
121 ms to 0.68 ms. A patch switching an externally tagged enum's variant
(texture pattern tile -> procedural) now replaces it instead of merging into
an invalid two-variant object.

Refs #95

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

* Brush Settings panel: Photoshop structure and depth, edits through tools.setBrush

- Sections split into brush_sections.rs: Brush Tip Shape with a tip grid of
  preset tips (relative-size thumbnails), Restore original size, Flip X/Y, a
  draggable angle/roundness ellipse with fields, Hardness (greyed for sampled
  tips), Spacing; Shape Dynamics, Scattering, Texture (pattern swatch, style,
  invert, scale, brightness, contrast, each tip, mode, depth, minimum depth,
  depth jitter), Dual Brush (mode, flip, tip grid, size, spacing, scatter,
  both axes, count), Color Dynamics, Transfer, Brush Pose (tilt now in ±100 %
  of the model's ±90°, was off by 90x), Build-up, Smoothing.
- Control pop-ups offer the direction controls only for Angle; Fade shows its
  step count; disabled sections show their options greyed; clicking a section
  name shows and turns it on.
- Every edit becomes one tools.setBrush with only the changed fields (bitmaps
  skipped unless changed), so the panel is journaled and drivable.
- brush_preview.rs: slot cache keyed on a cheap settings signature; the strip
  and thumbnails re-render only on change and no longer leak a texture per
  slider value.
- Brushes tab: size slider, search, collapsible groups (state in
  UiState.brushes_panel), tip thumbnail + stroke preview + name rows, new /
  delete / import buttons.

Refs #95

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:37:01 -04:00
Brandon Thomas b4b1aab4ad
Dock: fixed-height panel groups with splitters, collapse, reorder and a remembered layout (#139)
Panel group heights no longer follow their content (#88): switching between a
pixel and an adjustment layer used to grow Properties and squeeze Layers.

- New `dock` module: `DockLayout` (order, heights, collapsed) in `UiState::dock`
  (serde default, settable with `ui.set {dock}`). The last expanded group fills
  the column; content taller than its group scrolls inside it.
- Drag the gap between groups to resize (min heights, clamped to the window);
  double-click a tab or use the panel menu (hamburger, now live) to collapse,
  move a group up/down, or close it; drag a tab strip to reorder (blocked by
  Lock Workspace).
- Layout saved with New Workspace, reset by Reset Workspace (default heights,
  order and tabs), and remembered across launches in `prefs.panelLayout` while
  Remember Workspace Changes is on.
- #129: the icon rail toggled panel visibility, so a stray click made a panel
  vanish. It now shows / expands / collapses a docked group and never hides it.
  Window > <panel> and Window > Color/Gradients/... expand a collapsed group
  instead of hiding it, so the menu always brings a panel back.

Tests: kittests for stable rects across layer kinds (Pro and Studio), splitter
drag + UI-state round trip + prefs persistence, reset/new workspace, double-click
collapse and strip-drag reorder (and lock), tiny windows, rail/Window menu
recovery, and a click-around test over layers, canvas, toolbar and options bar.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:36:48 -04:00
Brandon Thomas 27e047f19f
Fix flaky canvas_geom undo test: isolate the text-gamma test in its own binary (#135)
blend_text_gamma_setting switched the compositor's process-wide text gamma
(psblend::set_text_gamma) to 1.0 and back while other engine unit tests ran
in parallel threads. turns_at_every_depth_and_mode flattens a document with
a type layer before a turn and again after edit.undo; when the toggle landed
between the two flattens, anti-aliased glyph edges blended under different
gammas and the bit-exact comparison failed (seen once on Windows CI).

Move the gamma test to crates/engine/tests/text_gamma.rs (its own process),
document the hazard on set_text_gamma, and make the undo assertion report
the turn, depth, mode, differing pixel count, max difference and first pixel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:36:37 -04:00
Brandon Thomas bb58aace98
Crop tool: move the frame, resize from its handles, Space repositions while drawing (#132)
Dragging inside an existing crop frame now moves it (Photoshop Classic Mode) instead of
starting a new one; edges and corners resize it (Shift keeps the aspect ratio, Alt resizes
about the centre, an options-bar ratio preset always holds); Space while drawing a new frame
moves it like the marquee tools in Photoshop. Cursors follow the hit (resize arrows, move,
crosshair), the overlay gains edge handles, and a moved frame snaps its edges.

New module crop_ui.rs; small hooks in canvas.rs, snap_ui.rs, shortcuts.rs, control.rs
(ui.pointer `space` flag), panels.rs (hint) and lib.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:25:18 -04:00
MikeeI ad7046bf99
fix(compose): scale artboard bounds in proxy documents (#131) 2026-10-05 01:25:06 -04:00
MikeeI f9f1efea6e
fix(io): preserve selected real PSD masks (#130) 2026-10-05 01:24:54 -04:00
MikeeI ac22c5ae1e
fix(io): gate flat export shortcut on active compositing properties (#122) 2026-10-05 01:24:42 -04:00
MikeeI e61bfa60eb
fix(io): warn when the PSD channel limit drops Quick Mask (#115) 2026-10-05 01:24:30 -04:00
MikeeI 1863164182
fix(engine): disambiguate colliding document identities (#114) 2026-10-05 01:24:18 -04:00
MikeeI 57085214da
fix(ui): prune closed-document display and preview resources (#118)
* fix(ui): prune display LUTs together with upload signatures

Own signatures with renderer resources and release closed-document LUTs even when no canvas texture remains. Invalidate preview upload markers at the same boundary so preserved-ID reopening rebuilds deleted resources.

* test(ui): cover closed preview owner invalidation

Extend the Display P3 reopen scenario to drop adjustment-preview state when its document closes and rebuild it after same-ID reopen.
2026-10-05 01:24:07 -04:00
MikeeI 68e5e6713b
fix(ui): release orphaned layer and mask thumbnails (#119)
Sweep live layer and mask keys only after snapshot or document membership changes. Retain the union across open documents because native files can restore IDs; undo recreates missing handles.
2026-10-05 01:23:55 -04:00
MikeeI 58531372f6
perf(ui): retain channel thumbnails across view-only revisions (#120)
Use Weak document snapshot identity rather than the general revision so channel targeting does not rebuild unchanged pixels. Snapshot replacement still invalidates edits and undo without retaining document pixels.
2026-10-05 01:23:43 -04:00
MikeeI 019d6a4dc2
perf(ui): skip clipped layer and mask thumbnail work (#117)
Keep row geometry and interactions intact while avoiding image preparation outside the clip rectangle. Preserve borders and smart-object badges that extend beyond the image.
2026-10-05 01:23:32 -04:00
MikeeI f8f581eea0
perf(ui): reuse exact tile bounds for selection and layer edges (#116)
Use the existing weak-identity cache rather than rescanning unchanged boundary pixels. Dense cold scans can cost more; repeated reads reuse exact bounds.
2026-10-05 01:23:20 -04:00
MikeeI 66fd3fc0fa
perf(ui): borrow the active layer from its document snapshot (#121)
Avoid cloning recursive group metadata and raster tile maps during Layers panel rendering. The existing local Arc owns the borrowed snapshot while queued actions execute afterward.
2026-10-05 01:23:08 -04:00
Brandon Thomas 47884ada39
Community batch follow-ups: on-demand OS clipboard paste; fmt + egui 0.36 test port (#134)
* chore: rustfmt and egui 0.36 test port for the community batch

PR #97/#98/#99/#101 code was not rustfmt-clean at max_width 160, and the
liquify/menu regression tests from #97/#98 used Context::run, which egui 0.36
removed: port them to run_ui, clear texture deltas, and let the popup area
lay out for a few frames before hit-testing it.

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

* fix(ui): read the OS clipboard only on an explicit paste

Follow-up to #102. Drop the 4 Hz background mirroring of the OS clipboard
in logic(): reading the whole system clipboard (possibly a large image)
without user action is a privacy problem (macOS warns about it) and wastes
CPU. Edit > Paste / Paste in Place are now enabled when the session
clipboard has content or an OS clipboard service exists (no read); the menu,
shortcut, palette and control paths import the OS image once, on the paste
itself, and keep #102's centring of external images. An empty or non-image
clipboard makes Paste a no-op with a status message instead of an error.

Tests: external_clipboard_enables_and_centres_paste adapted (enabled without
a read, exactly one read per paste, centred), plus an empty-clipboard no-op
test and idle_frames_never_read_the_os_clipboard (20 full app frames, zero
reads).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:03:16 -04:00
966 changed files with 238581 additions and 11034 deletions

18
.dockerignore Normal file
View File

@ -0,0 +1,18 @@
.git
.github
.agents
.codex
.aws
**/target
dist
corpus
log
plan
secrets
temp
craft-fonts
book/output
**/.DS_Store
**/.env
**/.env.*
*.log

4
.github/actionlint.yaml vendored Normal file
View File

@ -0,0 +1,4 @@
# Custom runner labels actionlint can't know about.
self-hosted-runner:
labels:
- macos-release # org larger runner (M2 Pro), used by release.yml

View File

@ -17,25 +17,69 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
# that lands while main's run is in progress waits for it, so every main state that gets a run is
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
name: select platforms
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: pick
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha }}
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
run: |
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
all='["ubuntu-latest","windows-latest"]'
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "os=$all" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "os=$all" >> "$GITHUB_OUTPUT"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
fi
test:
name: test (${{ matrix.os }})
needs: platforms
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: ${{ fromJSON(needs.platforms.outputs.os) }}
steps:
- uses: actions/checkout@v4
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
# background: the space is free before the craft-fonts build needs it, and nothing waits.
- name: Install Linux system dependencies (egui/winit/rfd)
if: runner.os == 'Linux'
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
@ -45,21 +89,92 @@ jobs:
with:
components: clippy, rustfmt
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
# A failing test still saves: the cache holds compiled dependencies, not test results.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
- name: Test
run: cargo test --workspace --locked
run: cargo test --workspace --locked --features heif
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace --locked --features heif
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
corpus:
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
name: corpus tests
runs-on: ubuntu-latest
env:
# test-corpus builds with --release, but here without the release profile's thin LTO and
# single codegen unit: they ran LTO over the dependency graph for each of the ~95 corpus test
# binaries, so building took 23.5 of the job's 37 minutes while the tests ran for 1.5.
# Turning them off builds 2.7x faster; the code under test is the same.
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Restore test corpora
uses: actions/cache@v4
with:
path: corpus/
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
- name: Fetch and verify corpora
run: cargo xtask corpus --all
- name: Corpus tests
run: cargo xtask test-corpus
wasm:
name: wasm32 check
runs-on: ubuntu-latest
@ -71,6 +186,8 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Check wasm-safe crates
run: cargo xtask wasm
@ -85,11 +202,15 @@ jobs:
target:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
- { crate: codecs, name: decode_tiff }
- { crate: codecs, name: decode_exr }
- { crate: codecs, name: decode_heif }
- { crate: affinity, name: preview }
- { crate: affinity, name: import_preview, args: "--features io" }
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
@ -101,7 +222,7 @@ jobs:
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
run: cargo fuzz run ${{ matrix.target.name }} -- -max_total_time=${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4

64
.github/workflows/freebsd.yml vendored Normal file
View File

@ -0,0 +1,64 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
# uploaded and no secrets are used.
name: FreeBSD
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
# device at once, so the GPU integration tests run one at a time here.
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'

View File

@ -34,7 +34,9 @@ jobs:
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
shell: pwsh
run: |
@ -44,10 +46,20 @@ jobs:
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/photocraft.wxs packaging/windows/photocraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft' and m['command'] == 'photocraft'"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF

91
.github/workflows/perf-nightly.yml vendored Normal file
View File

@ -0,0 +1,91 @@
name: Perf (nightly)
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
# more than 15 % against perf/baseline.json from the same machine class (other classes are
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
#
# To give this runner class its own baseline: run it by hand with "update_baseline", download
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
on:
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
inputs:
quick:
description: "Quick subset (small documents, no budgets)"
type: boolean
default: false
update_baseline:
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: perf-nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
perf:
name: perf (macOS, Apple GPU)
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
runs-on: macos-15
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: perf-release
- name: Run benchmarks
id: perf
env:
QUICK: ${{ inputs.quick }}
UPDATE: ${{ inputs.update_baseline }}
run: |
args=()
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
cargo xtask perf "${args[@]}"
- name: Job summary
if: always()
run: |
if [ -f target/perf/summary.md ]; then
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
else
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-results
path: |
target/perf/*.json
target/perf/summary.md
if-no-files-found: warn
retention-days: 30
- name: Upload baseline
if: always() && inputs.update_baseline
uses: actions/upload-artifact@v4
with:
name: perf-baseline
path: perf/baseline.json
if-no-files-found: warn
retention-days: 30

View File

@ -30,12 +30,16 @@ env:
RUST_BACKTRACE: 1
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (today the Japanese fonts). An optional build input for everyone else; see
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
@ -63,13 +67,19 @@ jobs:
macos:
name: macOS universal
needs: version
runs-on: macos-15
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@ -90,7 +100,18 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
@ -112,11 +133,20 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@ -129,7 +159,9 @@ jobs:
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
@ -141,6 +173,8 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
@ -154,7 +188,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
@ -167,12 +201,18 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@ -184,6 +224,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@ -194,6 +237,9 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@ -201,11 +247,101 @@ jobs:
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@ -226,6 +362,8 @@ jobs:
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
@ -236,7 +374,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:

93
.github/workflows/windows-arm64.yml vendored Normal file
View File

@ -0,0 +1,93 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
on:
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: photocraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: photocraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'PhotoCraft'
foreach ($exe in 'photocraft.exe', 'photocraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'photocraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok photocraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
Write-Output 'ok uninstalled'

3
.gitignore vendored
View File

@ -8,3 +8,6 @@ target/
/secrets
/temp
/book/output
/craft-fonts
*.log
__pycache__/

View File

@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`; the repository is not public — outside contributors should ask a maintainer for the standards relevant to their change). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
@ -11,9 +11,11 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
| `docs/roadmap.md` | Milestones and the **current focus** |
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@ -21,6 +23,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@ -32,7 +35,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
@ -44,7 +47,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | p
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
@ -54,7 +57,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
@ -67,9 +70,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
1. `docs/roadmap.md` → **Current focus**.
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
3. `log/devlog.md` → the "Still open" bullets of recent entries.
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
round trips, workflow tests, performance), update that section with the dated figure.
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
the performance scenarios that are over budget or not measurable yet, and the count of
settings that do nothing. Its numbers are measured; prefer them to estimates.
2. `docs/roadmap.md` → **Current focus**.
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
4. `log/devlog.md` → the "Still open" bullets of recent entries.
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
@ -82,8 +92,15 @@ cargo xtask layers
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
```
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
@ -101,6 +118,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
- `docs/parity.md`: generated Photoshop menu coverage.
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
@ -113,3 +131,21 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
nothing is silently dropped from `.pcraft` saves. When you add a doc field, add it to
`crates/format/src/manifest.rs` and `convert.rs` with `#[serde(default)]` so older files still load.
If the field has a PSD equivalent, map it in `crates/io` too, and keep unknown PSD blocks verbatim.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).

View File

@ -10,15 +10,43 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/pl.tsv` | Polish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) |
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
| `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
### Optional build input: craft-fonts (not files in this repo)
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
from it is committed here. Per-file authors, sources and licences:
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
| Font (embedded at build time) | Use | License |
|---|---|---|
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
@ -30,6 +58,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|---|---|---|---|---|
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
@ -48,6 +77,20 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
## Test data (not committed, not shipped)
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
`corpus/` is gitignored and no test fixtures are committed to the repository.
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (21 files, 2.4 MB) | Public Affinity documents, unmodified: `vector-art/` (9), `afdesignload/` (9), `jac21/` (2) and `asset-store-template/` (1), each with its upstream licence next to it | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo) | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate) | CC0-1.0 (vector-art); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.

1138
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
[workspace.dependencies]
# internal
@ -20,6 +20,7 @@ photocraft-ops = { path = "crates/ops" }
photocraft-psd = { path = "crates/psd" }
photocraft-codecs = { path = "crates/codecs" }
photocraft-raw = { path = "crates/raw" }
photocraft-affinity = { path = "crates/affinity" }
photocraft-compose = { path = "crates/compose" }
photocraft-gpu = { path = "crates/gpu" }
photocraft-paint = { path = "crates/paint" }
@ -33,6 +34,7 @@ photocraft-plugins = { path = "crates/plugins" }
photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
thiserror = "2"
@ -68,3 +70,33 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3

68
Dockerfile Normal file
View File

@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1
# Community-maintained self-hosting recipe (see packaging/web/README.md#docker); the official
# web build is the `web` job in .github/workflows/release.yml (packaging/web/package.sh).
#
# Source of truth for the toolchain: release.yml and ci.yml build with the latest *stable* Rust
# (dtolnay/rust-toolchain@stable), not a pinned version. This default is the stable release CI
# used when it was last bumped; raise it when CI's stable moves on. It must stay >= the
# workspace `rust-version` in Cargo.toml.
ARG RUST_VERSION=1.99
ARG NGINX_VERSION=1.28-alpine
FROM rust:${RUST_VERSION}-slim-bookworm AS builder
# Must match `TRUNK_VERSION` in the `web` job of .github/workflows/release.yml.
ARG TRUNK_VERSION=0.21.14
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-unknown-unknown
RUN set -eu; \
case "$(uname -m)" in \
x86_64) arch=x86_64 ;; \
aarch64) arch=aarch64 ;; \
*) echo 'The web builder supports amd64 and arm64' >&2; exit 1 ;; \
esac; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
trunk --version
WORKDIR /src
COPY . .
WORKDIR /src/apps/photocraft-web
# Docker serving has no static-host per-file size cap. Thin LTO uses less build
# memory than the release zip's fat LTO; retain the profile's other optimizations.
ARG CARGO_PROFILE_WASM_RELEASE_LTO=thin
ARG CARGO_BUILD_JOBS=1
# Optional build provenance shown in About and `--version` (crates/engine/src/build_info.rs).
# .git is not in the build context, so pass it in, e.g.
# --build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)
# Left empty, the build reports itself as a dev build.
ARG PHOTOCRAFT_BUILD_SHA=
ARG PHOTOCRAFT_BUILD_DATE=
# index.html selects the wasm-release profile, HEIF support and wasm-opt -Oz.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
--mount=type=cache,target=/root/.cache/trunk \
trunk build --release --locked \
&& test -s /src/dist/web/index.html \
&& gzip -9 -k /src/dist/web/*.wasm /src/dist/web/*.js /src/dist/web/index.html
FROM nginx:${NGINX_VERSION} AS runtime
COPY packaging/web/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /src/dist/web/ /usr/share/nginx/html/
COPY LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md /usr/share/nginx/html/
COPY assets/fonts/OFL-*.txt assets/icons/LICENSE-*.txt assets/dict/LICENSE-*.txt \
crates/ui-egui/src/i18n/LICENSE-translations.txt /usr/share/nginx/html/licenses/
# Bypass the stock entrypoint: this configuration needs no runtime rewriting.
USER nginx
EXPOSE 8080
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1

5
NOTICE
View File

@ -16,3 +16,8 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
ship with the package as OFL-<family>.txt.

117
README.md
View File

@ -17,7 +17,7 @@
<p align="center">
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="macOS · Windows · Linux · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
</p>
@ -52,6 +52,7 @@
<a href="#built-for-agents">Agents</a> ·
<a href="#under-the-hood">Under the hood</a> ·
<a href="#get-started">Get started</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">Crafting Apps</a> ·
<a href="https://discord.gg/artcraft">Discord</a>
</p>
@ -70,7 +71,7 @@
</td>
<td width="25%" valign="top">
<h3>🗂️ Real PSD files</h3>
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>
@ -121,7 +122,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<br>
<sub>A headline edited in place, with a byline and a paragraph of body text.<br><i>Among the Sierra Nevada, California</i>, Albert Bierstadt, 1868</sub>
<h3>Type that sets beautifully</h3>
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour.
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour. The <a href="docs/color-picker.md">full Type Color Picker</a> also supports screen sampling with a pixel loupe.
<br><br>
Type layers stay editable, take layer styles, and round-trip through PSD.
</td>
@ -177,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 34 tools</h4>
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
<h4>🧰 52 tools</h4>
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
@ -198,11 +199,11 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR and Radiance HDR, and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
Auto Tone, Contrast and Color · Equalize · Image and Canvas Size · Crop and Trim · Reveal All · Edit › Fill and Stroke · Copy Merged · Paste in Place · guides, rulers, grid and snapping · Actions record and replay · a command palette (⌘K).
Auto Tone, Contrast and Color · Equalize · Image and Canvas Size · Crop and Trim · Reveal All · Edit › Fill and Stroke · Copy Merged · Paste in Place · guides, rulers, grid and snapping · Actions record and replay · a command palette (⌘F).
</td>
</tr>
</table>
@ -213,7 +214,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@ -231,6 +232,9 @@ photocraft-cli run wave.psd \
# Apply one action list to a folder of images
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
# Every subcommand explains itself
photocraft-cli batch --help
# Let an agent drive it over MCP (headless, or bridged to the running app)
photocraft-cli mcp
```
@ -255,21 +259,109 @@ cargo run --release -p photocraft -- image.psd # the desktop app
cargo test --workspace # the test suite
```
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
```
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
```sh
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
flatpak run ai.storyteller.photocraft
```
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
```sh
ditto -x -k photocraft-cli-<version>-macos-universal.zip .
spctl --assess --type install -vv photocraft-cli-<version>-macos-universal/photocraft-cli
# ... accepted, source=Notarized Developer ID
```
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
```sh
pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
tar -xzf photocraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
photocraft
```
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
To self-host the browser version with Docker, see [Docker web hosting](packaging/web/README.md#docker).
## Security
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
## Test corpora
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
## Downloads
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `photocraft-<ver>-windows-x64.msi` | `photocraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `photocraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `photocraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `photocraft-<ver>-linux-x86_64.AppImage` | `photocraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `photocraft-<ver>-linux-x86_64.flatpak` | `photocraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `photocraft-<ver>-linux-x86_64.deb` | `photocraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `photocraft-<ver>-linux-x86_64.rpm` | `photocraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `photocraft-<ver>-linux-x86_64.tar.gz` | `photocraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `photocraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `photocraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
@ -282,7 +374,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
@ -336,5 +428,6 @@ Forks and modified versions must remove them.
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
ArtCraft
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/photocraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)

View File

@ -49,4 +49,4 @@ General bugs without a security impact should use the project's normal issue and
## Current limitations
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. Authentication does not provide per-client or per-tool capabilities, so an authenticated client still receives the exposed control surface. Remote filesystem methods use separately configured read and write workspace roots, and engine commands that still use ambient filesystem paths are denied at the automation boundary. Automation can change individual safe preferences, but whole-preferences updates and filesystem-bearing preference sections are refused; applying the Preferences dialog through automation is also refused. JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.

View File

@ -4,6 +4,12 @@ description = "Headless Photocraft: convert, inspect, run commands, batch, MCP s
version.workspace = true
edition.workspace = true
license.workspace = true
build = "build.rs"
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@ -25,3 +31,7 @@ photocraft-codecs = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"

View File

@ -0,0 +1,36 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `photocraft-cli.exe`.
//!
//! On every other target this does nothing, even when `PHOTOCRAFT_REQUIRE_WINRES` is set. A
//! missing resource compiler is a warning, so a cross-compile from macOS or Linux still links,
//! unless `PHOTOCRAFT_REQUIRE_WINRES=1` (set by the release packaging script) turns it into a
//! build error.
fn main() -> std::io::Result<()> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/photocraft.ico");
println!("cargo:rerun-if-env-changed=PHOTOCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return Ok(());
}
let mut res = winresource::WindowsResource::new();
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
// `1.2.3-rc.1` stays in the strings; the numeric fields are `1.2.3.0`.
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "PhotoCraft")
.set("FileDescription", "PhotoCraft command-line interface")
.set("CompanyName", "Learning Machines LLC")
.set("LegalCopyright", "Copyright (c) the PhotoCraft authors. MIT OR Apache-2.0.")
.set("OriginalFilename", "photocraft-cli.exe")
.set("InternalName", "photocraft-cli");
if let Err(err) = res.compile() {
if std::env::var_os("PHOTOCRAFT_REQUIRE_WINRES").is_some() {
return Err(std::io::Error::other(format!(
"embedding Windows resources into photocraft-cli.exe failed: {err}. \
Install the Windows SDK resource compiler (rc.exe) or mingw-w64 windres and rebuild."
)));
}
println!("cargo:warning=photocraft-cli.exe built without icon/version resources: {err}");
}
Ok(())
}

View File

@ -14,15 +14,20 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>]
Apply an action list ([{\"command\": id, \"params\": {…}}, …]) to every image in a directory.
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An --out folder that is the
--in folder is refused, as the results would replace the originals; --in-place allows it.
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
@ -33,8 +38,12 @@ USAGE:
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, engine.commands, doc.open/new/save/inspect/render/
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
an error.
";
struct Args {
@ -42,43 +51,67 @@ struct Args {
flags: Vec<(String, Option<String>)>,
}
const VALUE_FLAGS: &[&str] = &[
"--format",
"--quality",
"--new",
"--cmd",
"--params",
"--out",
"--actions",
"--in",
"--filter",
"--bridge",
"--port",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
struct Subcommand {
name: &'static str,
values: &'static [&'static str],
bare: &'static [&'static str],
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand { name: "run", values: &["--new", "--cmd", "--params", "--out", "--format", "--quality"], bare: &["--tiff-layers"], run: run_cmds },
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place", "--tiff-layers"], run: batch },
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
Subcommand {
name: "mcp",
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, _| mcp(a),
},
Subcommand {
name: "serve",
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, err| serve(a, err),
},
];
fn parse(args: &[String]) -> Result<Args, String> {
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
let mut i = 0;
while i < args.len() {
let s = &args[i];
while let Some(s) = args.get(i) {
if s == "--help" || s == "-h" {
return Ok(None);
}
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
if sub.bare.contains(&k) {
return Err(format!("{k} takes no value"));
}
if !sub.values.contains(&k) {
return Err(unknown(k));
}
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if VALUE_FLAGS.contains(&s.as_str()) {
} else if sub.values.contains(&s.as_str()) {
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if s.starts_with("--") {
} else if sub.bare.contains(&s.as_str()) {
a.flags.push((s.clone(), None));
} else if s.starts_with("--") {
return Err(unknown(s));
} else {
a.positional.push(s.clone());
}
i += 1;
}
Ok(a)
Ok(Some(a))
}
impl Args {
@ -92,28 +125,30 @@ impl Args {
type R = Result<(), String>;
/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error
/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts
/// the process on one (issue #1108).
pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let mut strings = Vec::with_capacity(args.len());
for (i, a) in args.iter().enumerate() {
match a.clone().into_string() {
Ok(s) => strings.push(s),
Err(raw) => {
let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy());
return 2;
}
}
}
run(&strings, out, err)
}
/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage).
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let Some(cmd) = args.first() else {
let _ = write!(err, "{USAGE}");
return 2;
};
let parsed = match parse(&args[1..]) {
Ok(p) => p,
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
}
};
let r = match cmd.as_str() {
"convert" => convert(&parsed, out, err),
"info" => info(&parsed, out),
"run" => run_cmds(&parsed, out, err),
"batch" => batch(&parsed, out, err),
"commands" => commands(&parsed, out),
"droplet" => droplet(&parsed, out, err),
"mcp" => mcp(&parsed),
"serve" => serve(&parsed, err),
match cmd.as_str() {
"-h" | "--help" | "help" => {
let _ = write!(out, "{USAGE}");
return 0;
@ -122,12 +157,25 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
other => {
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
_ => {}
}
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
return 2;
};
let parsed = match parse(sub, &args[1..]) {
Ok(Some(p)) => p,
// `<subcommand> --help`: usage, without reading inputs or starting a server.
Ok(None) => {
let _ = write!(out, "{USAGE}");
return 0;
}
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
}
};
match r {
match (sub.run)(&parsed, out, err) {
Ok(()) => 0,
Err(e) => {
let _ = writeln!(err, "error: {e}");
@ -137,10 +185,14 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
}
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
let mut o = ExportOptions::default();
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
if let Some(q) = a.get("--quality") {
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
o.encode.jpeg_quality = q.clamp(1, 100);
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
Ok(o)
}
@ -151,6 +203,12 @@ fn warn_all(err: &mut dyn Write, ws: &[String]) {
}
}
/// The engine knows which type families aren't installed. Warn before an export
/// uses a fallback face rather than silently changing the appearance of the text.
fn missing_font_warnings(fonts: Vec<String>) -> Vec<String> {
fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect()
}
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
.map_err(|error| error.to_string())
@ -160,9 +218,11 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
}
@ -179,7 +239,11 @@ fn info(a: &Args, out: &mut dyn Write) -> R {
let mut h = Headless::trusted_local();
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
doc["warnings"] = opened["warnings"].clone();
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file);
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
@ -207,6 +271,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
}
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
@ -216,7 +281,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
h.command_run("file.new", p).map_err(|e| e.to_string())?;
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
}
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
}
@ -224,38 +289,61 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
print_json(out, &json!({"command": id, "result": r}), true)?;
let new_warnings: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, true)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
}
Ok(())
}
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = match &v {
Value::Array(a) => a.clone(),
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
_ => return Err("actions JSON: expected an array".into()),
};
list.into_iter()
.enumerate()
.map(|(i, a)| {
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
})
.collect()
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
fn is_input(p: &Path) -> bool {
let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read);
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
@ -266,16 +354,29 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let actions = parse_actions(&text)?;
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
return Err(format!(
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
out_dir.display()
));
}
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let mut inputs: Vec<PathBuf> =
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>, String> {
written.check(&target_text)?;
let mut h = Headless::trusted_local();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
@ -287,6 +388,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
@ -382,3 +484,88 @@ fn mcp(a: &Args) -> R {
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let mut output = Vec::new();
let mut errors = Vec::new();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
}
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let (code, stdout, stderr) = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}

View File

@ -1,7 +1,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let code = photocraft_cli::run(&args, &mut std::io::stdout(), &mut std::io::stderr());
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a
// usage error, not a panic before anything is printed (issue #1108).
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr());
std::process::exit(code);
}

View File

@ -43,6 +43,63 @@ fn usage_and_unknown_command() {
assert!(out.starts_with("photocraft-cli "));
}
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
/// written, no server started), whatever else is on the line.
#[test]
fn every_subcommand_answers_help() {
let d = tmp("help");
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
for help in ["--help", "-h"] {
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
}
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
#[test]
fn unknown_flags_are_usage_errors() {
let d = tmp("flags");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let out_dir = d.join("out");
let fails = |cmd: &mut Command, flag: &str| {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
};
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
assert!(!out_dir.exists(), "nothing written");
}
let x = d.join("x.png");
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
assert!(!x.exists());
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
"80",
]));
assert!(out_dir.join("a.jpg").exists());
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
#[test]
fn convert_png_pcraft_png_is_lossless() {
let d = tmp("convert");
@ -79,13 +136,45 @@ fn info_prints_layer_tree_json() {
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["width"], 10);
assert_eq!(v["height"], 6);
assert_eq!(v["layers"][0]["name"], "Background");
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
assert_eq!(v["layers"][0]["name"], "Layer 0");
assert!(v.get("history").is_none());
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
assert_eq!(out.trim().lines().count(), 1);
std::fs::remove_dir_all(d).unwrap();
}
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
#[test]
fn info_and_convert_report_decode_warnings() {
let d = tmp("decode-warnings");
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
// Three 1x1 frames.
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
for _ in 0..3 {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
let cases = [
("whole.jpg", &jpeg[..], None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
];
for (name, bytes, warning) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_commands_and_save() {
let d = tmp("run");
@ -121,6 +210,15 @@ fn run_errors() {
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
// Params must be a JSON object, for `--new` and for each `--params`.
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let e = String::from_utf8_lossy(&o.stderr);
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let e = String::from_utf8_lossy(&o.stderr);
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
}
#[test]
@ -155,6 +253,157 @@ fn parse_actions_formats() {
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
assert!(photocraft_cli::parse_actions("7").is_err());
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
for text in [
r#"[["a",{"x":1}],["b"]]"#,
r#"[["a",{"x":1}],"b"]"#,
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
] {
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
}
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
}
}
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
let file = d.join(format!("{name}.json"));
std::fs::write(&file, actions).unwrap();
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
}
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
/// as the object form; a malformed step fails before anything is written.
#[test]
fn batch_accepts_recorded_action_steps() {
let d = tmp("batch-steps");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 6, 5, 9);
let shapes = [
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
("pairs", r#"[["image.adjustments.invert",{}]]"#),
("ids", r#"["image.adjustments.invert"]"#),
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
];
for (name, actions) in shapes {
let o = batch_with(&d, &input, name, actions, &[]);
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
for f in ["a.png", "b.png"] {
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
}
}
let o = batch_with(&d, &input, "bad", "[42]", &[]);
assert_eq!(o.status.code(), Some(1));
assert!(!d.join("bad").exists(), "nothing written");
}
/// #490: a leading dot on `--format` doesn't double the dot in output names.
#[test]
fn batch_format_with_leading_dot() {
let d = tmp("batch-dot");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 6, 5, 9);
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
let o = batch_with(&d, &input, name, invert, &["--format", format]);
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
}
}
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
/// and nothing is written.
#[test]
fn quality_outside_1_to_100_is_refused() {
let d = tmp("quality");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
let a = input.join("a.png");
write_png(&a, 8, 4, 3);
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
let out = d.join("q.jpg");
let (mut convert, mut run) = (bin(), bin());
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
assert_eq!(o.status.code(), Some(1), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
assert!(!out.exists(), "--quality {q} wrote a file");
}
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
}
// The documented range still encodes, lower quality making a smaller file.
let sizes: Vec<usize> = ["1", "50", "100"]
.iter()
.map(|q| {
let out = d.join(format!("q{q}.jpg"));
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
std::fs::read(out).unwrap().len()
})
.collect();
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
fn batch_refuses_to_write_over_its_inputs() {
let d = tmp("batch-in-place");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let snapshot = || {
let mut files: Vec<(PathBuf, Vec<u8>)> =
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
files.sort();
files
};
let before = snapshot();
let batch = |out: &Path, extra: &[&str]| {
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
};
#[cfg(unix)]
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
let link = cfg!(unix).then(|| PathBuf::from("link"));
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
for extra in [&[][..], &["--format", "tif"][..]] {
let o = batch(out, extra);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
}
}
// A separate folder runs as before, leaving the originals alone.
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
assert_eq!(snapshot(), before);
// `--in-place` opts in: each result replaces its original.
let o = batch(Path::new("in"), &["--in-place"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let after = snapshot();
assert_eq!(after.len(), 2);
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
#[test]
@ -218,6 +467,38 @@ fn mcp_stdio_handshake_and_tool_call() {
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
let d = tmp("batch-collide");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
// Same stem in other formats; `A.tif` differs only in case.
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
}
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let out_dir = d.join("out");
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(
!o.status.success(),
"a lost result must fail the run
{out}
{err}"
);
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
assert_eq!(err.matches("not written").count(), 2, "{err}");
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
}
#[test]
fn droplet_runs_an_action_on_files() {
let d = tmp("droplet");
@ -237,3 +518,48 @@ fn droplet_runs_an_action_on_files() {
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
assert!(!o.status.success(), "not a droplet");
}
#[test]
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
let d = tmp("tiff-layers");
let layer_count = |path: &std::path::Path| -> usize {
let (info, _) = ok(bin().arg("info").arg(path));
let v: Value = serde_json::from_str(&info).unwrap();
v["layers"].as_array().unwrap().len()
};
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
let out = d.join(name);
let mut cmd = bin();
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
if let Some(flag) = flag {
cmd.arg(flag);
}
ok(cmd.arg("--out").arg(&out));
assert_eq!(layer_count(&out), layers, "{name}");
}
// convert honours the same flag.
let src = d.join("layered.tif");
let flat = d.join("converted.tif");
ok(bin().arg("convert").arg(&src).arg(&flat));
assert_eq!(layer_count(&flat), 1);
let kept = d.join("kept.tif");
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
assert_eq!(layer_count(&kept), 2);
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
#[cfg(unix)]
#[test]
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
let o = bin().args(&args).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
assert!(!err.contains("panicked"), "{err}");
}
}

View File

@ -0,0 +1,146 @@
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
//! changes nothing.
use std::path::{Path, PathBuf};
use std::process::Command;
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
use serde_json::Value;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(cmd: &mut Command) -> String {
let o = cmd.output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
out
}
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
fn exif(o: u16) -> Vec<u8> {
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
v.extend_from_slice(&o.to_le_bytes());
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
v.extend_from_slice(b"PC1\0");
v.extend_from_slice(&[0, 0, 0, 0]);
v
}
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
fn upright_color(x: usize, y: usize) -> [u8; 3] {
match (x < 8, y < 16) {
(true, true) => [230, 20, 20],
(false, true) => [20, 230, 20],
_ => [20, 20, 230],
}
}
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
fn phone_jpeg() -> Vec<u8> {
let (w, h) = (16, 32);
let mut px = vec![0u8; w * h * 3];
for y in 0..h {
for x in 0..w {
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
let (sx, sy) = (y, w - 1 - x);
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
}
}
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
let jpeg =
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
let mut seg = b"Exif\0\0".to_vec();
seg.extend_from_slice(&exif(6));
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xFF, 0xE1]);
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&seg);
out.extend_from_slice(&jpeg[2..]);
out
}
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
fn stored(path: &Path) -> Image {
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
}
fn assert_portrait_pixels(img: &Image) {
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
let i = (y * 16 + x) * 3;
let got = &img.data()[i..i + 3];
let want = upright_color(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
}
}
#[test]
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let d = tmp("e2e");
let jpg = d.join("IMG_0006.JPG");
std::fs::write(&jpg, phone_jpeg()).unwrap();
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
let png = d.join("upright.png");
ok(bin().arg("convert").arg(&jpg).arg(&png));
let raw = stored(&png);
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
assert_portrait_pixels(&raw);
if let Some(e) = raw.meta.exif.as_deref() {
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
}
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
let turned = d.join("turned.png");
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
let t = stored(&turned);
assert_eq!(t.dimensions(), (32, 16));
if let Some(e) = t.meta.exif.as_deref() {
assert_eq!(exif_orientation(e), 1);
}
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
// .pcraft save, reopen and save again: identical document and pixels.
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
v
};
let (ia, ib) = (info(&a), info(&b));
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
ok(bin().arg("convert").arg(&b).arg(&pb));
let (xa, xb) = (stored(&pa), stored(&pb));
assert_eq!(xa.dimensions(), (16, 32));
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
assert_eq!(xa.meta.exif, xb.meta.exif);
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
std::fs::remove_dir_all(d).unwrap();
}

View File

@ -6,6 +6,11 @@ edition.workspace = true
license.workspace = true
publish = false
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@ -29,4 +34,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response"] }

View File

@ -4,16 +4,27 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#photocraft_loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
</style>
</head>
<body>
<canvas id="photocraft_canvas"></canvas>
<div id="photocraft_loading">Loading PhotoCraft&hellip;</div>
<div id="photocraft_loading">
<p>Loading PhotoCraft&hellip;</p>
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
</body>
</html>

View File

@ -28,3 +28,18 @@ fn main() {
fn main() {
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
}
#[cfg(test)]
mod tests {
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
/// retry, without a hand-written script.
#[test]
fn loading_page_offers_a_reload_when_startup_stalls() {
let html = include_str!("../index.html");
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
}
}

View File

@ -1,24 +1,45 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_engine::Session;
use photocraft_ui_egui::served_fonts;
use photocraft_ui_egui::theme::ThemeKind;
use photocraft_ui_egui::{PhotocraftApp, Services};
use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Services};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
"afdesign", "afphoto", "afpub",
];
const CANVAS_ID: &str = "photocraft_canvas";
/// Fonts the host serves next to the page (`photocraft_ui_egui::served_fonts`): craft-fonts' manifest
/// format, each file relative to the site root. No manifest (a 404) means no served fonts.
const FONTS_MANIFEST: &str = "fonts/manifest.txt";
/// The served fonts: the manifest once read, and what the network has delivered.
#[derive(Default)]
struct ServedFonts {
/// Font files by family.
files: HashMap<String, Vec<String>>,
/// Families fetched or being fetched: each one once per page load.
fetched: HashSet<String>,
/// Downloaded files waiting for the next frame: (family, bytes).
arrived: Vec<(String, Vec<u8>)>,
}
type Served = Arc<Mutex<ServedFonts>>;
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@ -47,7 +68,9 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone()));
listen_pen(&pen_target, app.stylus.feed.clone());
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
if let Some(rs) = cc.wgpu_render_state.clone()
@ -56,7 +79,9 @@ pub fn start() {
log::info!("photocraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
app.set_wgpu(rs);
}
Ok(Box::new(WebShell { app, inbox }))
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, unsaved, served }))
}),
)
.await;
@ -69,7 +94,7 @@ pub fn start() {
});
}
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
/// Pen pressure, tilt, twist and the eraser button from Pointer Events (eframe forwards none of them for pens) into
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
@ -83,7 +108,15 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
return;
}
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
// W3C Pointer Events: `buttons` bit 5 (32) is the pen's eraser.
let eraser = e.buttons() & 32 != 0;
feed.set(pen.then(|| PenSample {
pressure: e.pressure(),
tilt_x: e.tilt_x() as f32,
tilt_y: e.tilt_y() as f32,
rotation: e.twist() as f32,
eraser,
}));
});
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
@ -91,15 +124,109 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
}
}
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
fn guard_unload(unsaved: Arc<AtomicBool>) {
use wasm_bindgen::closure::Closure;
let Some(window) = web_sys::window() else { return };
let cb = Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
if unsaved.load(Ordering::Relaxed) {
e.prevent_default();
// Older browsers show the prompt only when a return value is set.
e.set_return_value("");
}
});
if window.add_event_listener_with_callback("beforeunload", cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
}
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Reads the served font manifest, if the host has one, and lists its families in the font menus.
fn load_font_manifest(served: Served, ctx: egui::Context) {
wasm_bindgen_futures::spawn_local(async move {
// Most hosts serve no fonts: a missing manifest is the normal case, not an error.
let Ok(bytes) = fetch_bytes(FONTS_MANIFEST).await else { return };
let text = String::from_utf8_lossy(&bytes);
// A host that answers unknown paths with its HTML page (single-page app fallback).
if text.trim_start().starts_with('<') {
return;
}
let (fonts, skipped) = served_fonts::parse_manifest(&text);
for s in skipped {
log::warn!("{FONTS_MANIFEST}: skipped {s}");
}
let mut files: HashMap<String, Vec<String>> = HashMap::new();
for f in fonts {
files.entry(f.family).or_default().push(f.file);
}
log::info!("photocraft-web: {} served font families", files.len());
let families: Vec<String> = files.keys().cloned().collect();
served.lock().unwrap_or_else(|e| e.into_inner()).files = files;
served_fonts::add_families(families);
ctx.request_repaint();
});
}
/// GET `url` (relative to the page) and read the whole body.
async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(url)).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let body = JsFuture::from(resp.array_buffer().map_err(js)?).await.map_err(js)?;
Ok(js_sys::Uint8Array::new(&body).to_vec())
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
/// so the app's own drop path can't handle them) and feed them through the inbox.
struct WebShell {
app: PhotocraftApp,
inbox: Inbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc<AtomicBool>,
served: Served,
}
impl WebShell {
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
/// installs the files that arrived.
fn serve_fonts(&mut self, ctx: &egui::Context) {
let requested = served_fonts::take_requests();
let (fetch, arrived) = {
let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner());
let mut fetch = Vec::new();
for family in requested {
if let Some(files) = s.files.get(&family).cloned()
&& s.fetched.insert(family.clone())
{
fetch.extend(files.into_iter().map(|file| (family.clone(), file)));
}
}
(fetch, std::mem::take(&mut s.arrived))
};
for (family, file) in fetch {
let served = self.served.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
match fetch_bytes(&file).await {
Ok(bytes) => {
served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't fetch served font {file}: {e}"),
}
});
}
served_fonts::install(&mut self.app, arrived);
}
}
impl eframe::App for WebShell {
@ -119,7 +246,9 @@ impl eframe::App for WebShell {
}
});
}
self.serve_fonts(ctx);
self.app.logic(ctx, frame);
self.unsaved.store(self.app.has_unsaved_work(), Ordering::Relaxed);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@ -127,34 +256,43 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
fn services(inbox: Inbox) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
return;
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the file's contents, not a path.
FileDialogRequest::Open { extensions, .. } => wasm_bindgen_futures::spawn_local(async move {
let dialog = if let Some(exts) = extensions {
rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts)
} else {
rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS)
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
None
})),
// No save dialog on the web: the suggested name becomes the download name.
pick_save: Some(Box::new(|suggested: &str| {
let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string());
Some(name)
let picked = dialog.pick_file().await;
let answer = match picked {
Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)),
None => None,
};
reply.send(answer);
}),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
reply.send(Some(FileDialogAnswer::SaveTo(name)));
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
encode_png: Some(Box::new(|w, h, rgba| {
@ -213,3 +351,72 @@ fn mime_for(name: &str) -> &'static str {
_ => "application/octet-stream",
}
}
/// Rust bindings to the browser's user-activated EyeDropper API; unsupported browsers keep
/// the document picker and its normal canvas zoom. The browser owns its screen magnifier.
fn screen_color_service() -> Option<photocraft_ui_egui::screen_picker::Service> {
use js_sys::{Function, Reflect};
use photocraft_ui_egui::screen_picker::{Capture, Pending};
use wasm_bindgen::{JsValue, closure::Closure};
let window = web_sys::window()?;
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
Some(Box::new(move |ctx| {
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = (|| -> Result<_, String> {
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(|e| format!("Could not start browser eyedropper: {e:?}"))?;
let controller = web_sys::AbortController::new().map_err(|e| format!("Could not create eyedropper cancellation: {e:?}"))?;
let options = js_sys::Object::new();
Reflect::set(&options, &JsValue::from_str("signal"), &controller.signal()).map_err(|e| format!("Could not set eyedropper options: {e:?}"))?;
let open = Reflect::get(&eye, &JsValue::from_str("open"))
.map_err(|e| format!("Browser eyedropper has no open method: {e:?}"))?
.dyn_into::<Function>()
.map_err(|_| "Invalid browser eyedropper method")?;
let promise = open
.call1(&eye, &options)
.map_err(|e| format!("Could not open browser eyedropper: {e:?}"))?
.dyn_into::<js_sys::Promise>()
.map_err(|_| "Browser eyedropper returned no promise")?;
let stop = cancelled.clone();
let abort = controller.clone();
let timer = Closure::<dyn FnMut()>::new(move || {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
abort.abort();
}
});
let interval = window.set_interval_with_callback_and_timeout_and_arguments_0(timer.as_ref().unchecked_ref(), 50).map_err(|e| {
controller.abort();
format!("Could not watch eyedropper cancellation: {e:?}")
})?;
Ok((promise, timer, interval))
})();
match started {
Err(e) => {
let _ = tx.send(Err(e));
}
Ok((promise, timer, interval)) => {
let wake = ctx.clone();
let window = window.clone();
wasm_bindgen_futures::spawn_local(async move {
let result = match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(value) => Reflect::get(&value, &JsValue::from_str("sRGBHex"))
.ok()
.and_then(|v| v.as_string())
.and_then(|h| photocraft_ui_egui::color_picker_ui::parse_hex(&h))
.map(|rgb| Capture::Color(Some(rgb)))
.ok_or_else(|| "Browser eyedropper returned an invalid color".into()),
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|v| v.as_string()).as_deref() == Some("AbortError") => {
Ok(Capture::Color(None))
}
Err(e) => Err(format!("Browser screen color selection failed: {e:?}")),
};
window.clear_interval_with_handle(interval);
drop(timer);
let _ = tx.send(result);
wake.request_repaint();
});
}
}
Pending { receiver: rx, cancelled }
}))
}

View File

@ -7,6 +7,11 @@ default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
build = "build.rs"
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@ -22,21 +27,67 @@ photocraft-io = { workspace = true }
photocraft-format = { workspace = true }
photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = "3"
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
[target.'cfg(target_os = "macos")'.dependencies]
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link"] }
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs).
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr"] }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
# Already used by the portal stack; bounded waits let cancelling close the OS request.
futures-lite = "=2.6.1"
async-io = "=2.6.0"
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
# its own. Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "windows")'.dependencies]
# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
xcap = "=0.9.8"
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSString"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSGraphicsContext", "NSMenu", "NSMenuItem", "NSResponder"] }
[[test]]
name = "mac_menu_appkit"
harness = false
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.

View File

@ -0,0 +1,234 @@
//! Where the desktop app keeps its per-user data: preferences, brush presets, crash-recovery
//! autosaves and the GPU startup marker all live under the one directory [`config_dir`] returns.
//!
//! In order:
//! 1. `PHOTOCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
//! 2. **Portable mode** (#228): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts
//! everything in `<exe dir>/PhotoCraftData`, so a portable copy on a USB stick leaves nothing in
//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "PhotoCraft.portable"];
/// The data folder created beside the executable in portable mode.
pub const PORTABLE_DATA_DIR: &str = "PhotoCraftData";
/// How [`DataDir::dir`] was chosen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// `PHOTOCRAFT_CONFIG_DIR`.
Override,
/// `<exe dir>/PhotoCraftData`.
Portable,
/// The platform's per-user config directory.
Platform,
}
/// The resolved data directory.
#[derive(Clone, Debug)]
pub struct DataDir {
/// `None` only when no platform directory is known either (no `HOME`/`APPDATA`).
pub dir: Option<PathBuf>,
pub mode: Mode,
/// Set when a portable marker was found but its data folder isn't writable.
pub warning: Option<String>,
}
/// The data directory for this process, resolved once (the portable check probes the disk).
pub fn current() -> &'static DataDir {
static DIR: OnceLock<DataDir> = OnceLock::new();
DIR.get_or_init(|| {
let exe = std::env::current_exe().ok();
let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent));
if let Some(w) = &d.warning {
log::warn!("{w}");
eprintln!("photocraft: {w}");
}
log::info!("settings directory ({:?}): {:?}", d.mode, d.dir);
d
})
}
/// The per-user settings directory (see the module docs).
pub fn config_dir() -> Option<PathBuf> {
current().dir.clone()
}
/// Resolve the data directory from the environment (`env`) and the executable's folder.
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None };
}
let platform = platform_dir(&env);
let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else {
return DataDir { dir: platform, mode: Mode::Platform, warning: None };
};
let data = exe_dir.join(PORTABLE_DATA_DIR);
match ensure_writable(&data) {
Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None },
Err(e) => {
let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string());
let warning =
format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display());
DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) }
}
}
}
/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`).
pub fn platform_dir(env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
if cfg!(target_os = "macos") {
return home.map(|h| h.join("Library/Application Support/Photocraft"));
}
if cfg!(windows) {
return env("APPDATA").filter(|a| !a.is_empty()).map(|a| PathBuf::from(a).join("Photocraft"));
}
env("XDG_CONFIG_HOME").filter(|x| !x.is_empty()).map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
}
/// The first portable marker file in `exe_dir`, if any.
pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
}
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
// A leftover probe is harmless; don't fail portable mode over it.
let _ = std::fs::remove_file(&probe);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option<OsString> + use<> {
let m: HashMap<String, OsString> = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect();
move |k| m.get(k).cloned()
}
/// A fake user folder that every platform's convention resolves under.
fn fake_home(root: &Path) -> impl Fn(&str) -> Option<OsString> + use<> {
let home = root.join("home");
let appdata = root.join("appdata");
env_of(&[("HOME", &home), ("APPDATA", &appdata)])
}
#[test]
fn no_marker_uses_the_platform_dir() {
let root = temp("platform");
let exe = root.join("bin");
std::fs::create_dir_all(&exe).unwrap();
let env = fake_home(&root);
let d = resolve(&env, Some(&exe));
assert_eq!(d.mode, Mode::Platform);
assert!(d.warning.is_none());
let dir = d.dir.unwrap();
assert_eq!(Some(dir.clone()), platform_dir(&env));
assert!(!dir.starts_with(&exe));
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker");
// The convention per platform.
if cfg!(target_os = "macos") {
assert_eq!(dir, root.join("home/Library/Application Support/Photocraft"));
} else if cfg!(windows) {
assert_eq!(dir, root.join("appdata").join("Photocraft"));
} else {
assert_eq!(dir, root.join("home/.config/photocraft"));
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn either_marker_keeps_everything_beside_the_exe() {
for marker in PORTABLE_MARKERS {
let root = temp("portable");
let exe = root.join("PhotoCraft");
std::fs::create_dir_all(&exe).unwrap();
std::fs::write(exe.join(marker), b"").unwrap();
let d = resolve(fake_home(&root), Some(&exe));
assert_eq!(d.mode, Mode::Portable, "{marker}");
assert!(d.warning.is_none());
let dir = d.dir.unwrap();
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR));
assert!(dir.is_dir(), "the data folder is created");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
// Every derived path sits under the exe dir.
for sub in ["preferences.json", "Presets", "Recovery", crate::gpu_startup::MARKER_FILE] {
assert!(dir.join(sub).starts_with(&exe));
}
assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder");
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn a_marker_folder_is_not_a_marker() {
let root = temp("dir-marker");
let exe = root.join("bin");
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unwritable_portable_folder_falls_back_with_a_warning() {
let root = temp("unwritable");
let exe = root.join("bin");
std::fs::create_dir_all(&exe).unwrap();
std::fs::write(exe.join("portable.txt"), b"").unwrap();
// A file where the data folder should go: it can't be created, on every platform.
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
let env = fake_home(&root);
let d = resolve(&env, Some(&exe));
assert_eq!(d.mode, Mode::Platform);
assert_eq!(d.dir, platform_dir(&env));
let w = d.warning.unwrap();
assert!(w.contains("portable") && w.contains("isn't writable"), "{w}");
// No user folder at all: still no panic, just no directory.
let d = resolve(|_| None, Some(&exe));
assert!(d.dir.is_none() && d.warning.is_some());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn override_wins_over_portable() {
let root = temp("override");
let exe = root.join("bin");
std::fs::create_dir_all(&exe).unwrap();
std::fs::write(exe.join("portable.txt"), b"").unwrap();
let custom = root.join("custom");
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &custom)]), Some(&exe));
assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom)));
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_exe_dir_or_env_never_panics() {
let d = resolve(|_| None, None);
assert_eq!((d.mode, d.dir), (Mode::Platform, None));
let empty = PathBuf::new();
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
assert_eq!(d.dir, None, "empty variables are ignored");
}
}

View File

@ -0,0 +1,122 @@
//! The app icon, everywhere Windows, macOS and Linux show one (#248).
//!
//! - **Executable:** `build.rs` embeds `assets/app-icon/photocraft.ico` (16–256 px) as icon
//! resource 1, which Explorer, Alt+Tab and an unpinned taskbar button fall back to.
//! - **Window:** [`window_icon`] sets the title-bar and taskbar icon at runtime (`WM_SETICON` on
//! Windows, `_NET_WM_ICON` on X11, the Dock when running unbundled on macOS).
//! - **Start Menu shortcut (MSI):** the taskbar matches a running window to the shortcut that
//! launches its executable and shows that shortcut's icon. Windows Installer caches an advertised
//! shortcut's icon under its `<Icon Id>`, so that id must carry the target's `.exe` extension
//! (ICE50); an extensionless id showed a blank page on the taskbar. The tests below check it.
//!
//! The process keeps Windows' implicit AppUserModelID (derived from the executable path), which
//! is what makes that matching work for both the MSI shortcut and the portable exe. An explicit
//! ID would need a Win32 call (`unsafe`, which the workspace forbids) and would then also have to
//! be stamped on the shortcut, or the taskbar would stop matching the two.
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
pub fn window_icon() -> egui::IconData {
match eframe::icon_data::from_png_bytes(PNG) {
Ok(icon) => icon,
Err(e) => {
log::warn!("app icon: {e}");
egui::IconData::default()
}
}
}
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
#[cfg(test)]
mod tests {
use super::*;
const ICO: &[u8] = include_bytes!("../../../assets/app-icon/photocraft.ico");
const WXS: &str = include_str!("../../../packaging/windows/photocraft.wxs");
fn u16_at(b: &[u8], i: usize) -> usize {
u16::from_le_bytes([b[i], b[i + 1]]) as usize
}
fn u32_at(b: &[u8], i: usize) -> usize {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
assert!(icon.width >= 256 && icon.width == icon.height, "{}x{}", icon.width, icon.height);
assert_eq!(icon.rgba.len(), icon.width as usize * icon.height as usize * 4);
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
}
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
#[test]
fn exe_icon_has_every_shell_size() {
assert_eq!((u16_at(ICO, 0), u16_at(ICO, 2)), (0, 1), "ICONDIR header, type 1 (icon)");
let n = u16_at(ICO, 4);
let mut sizes = Vec::new();
for i in 0..n {
let e = 6 + i * 16;
let w = if ICO[e] == 0 { 256 } else { ICO[e] as usize };
let h = if ICO[e + 1] == 0 { 256 } else { ICO[e + 1] as usize };
assert_eq!(w, h);
let (len, off) = (u32_at(ICO, e + 8), u32_at(ICO, e + 12));
let data = ICO.get(off..off + len).expect("image data inside the file");
let png = data.starts_with(b"\x89PNG\r\n\x1a\n");
let bmp = data.len() >= 40 && u32_at(data, 0) == 40;
assert!(png || bmp, "{w}px entry is neither PNG nor BMP");
if png {
// IHDR width/height match the directory entry.
let pw = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
assert_eq!(pw, w, "{w}px entry holds a {pw}px PNG");
}
sizes.push(w);
}
for want in [16, 24, 32, 48, 64, 256] {
assert!(sizes.contains(&want), "photocraft.ico lacks {want}px (has {sizes:?})");
}
}
/// The value of `attr="…"` inside `element`.
fn attr<'a>(element: &'a str, attr: &str) -> Option<&'a str> {
let key = format!(" {attr}=\"");
let start = element.find(&key)? + key.len();
element[start..].split('"').next()
}
/// Each `<tag …>` element's text up to its closing `>`.
fn elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
let open = format!("<{tag} ");
xml.match_indices(&open).map(|(i, _)| &xml[i..i + xml[i..].find('>').unwrap()]).collect()
}
/// ICE50 (#248): advertised shortcut icons are cached under their `<Icon Id>`; without the
/// target's extension the shell shows a blank page on the Start Menu and the taskbar.
#[test]
fn msi_shortcut_icons_keep_the_exe_extension() {
let icons: Vec<&str> = elements(WXS, "Icon").iter().filter_map(|e| attr(e, "Id")).collect();
assert!(!icons.is_empty());
for id in &icons {
assert!(id.ends_with(".exe") || id.ends_with(".ico"), "<Icon Id=\"{id}\"> needs an .exe or .ico extension");
}
let shortcuts = elements(WXS, "Shortcut");
assert!(!shortcuts.is_empty());
for s in shortcuts {
let icon = attr(s, "Icon").expect("shortcuts name their icon");
assert!(icons.contains(&icon), "shortcut icon {icon} isn't an <Icon>");
if attr(s, "Advertise") == Some("yes") {
// Every shortcut in the MSI targets photocraft.exe.
assert!(icon.ends_with(".exe"), "advertised shortcut icon {icon} must end in .exe like its target (ICE50)");
}
}
let arp = WXS.split("Id=\"ARPPRODUCTICON\" Value=\"").nth(1).and_then(|r| r.split('"').next()).expect("ARPPRODUCTICON");
assert!(icons.contains(&arp), "ARPPRODUCTICON {arp} isn't an <Icon>");
// The Start Menu shortcut must not set its own AppUserModelID: the process keeps the
// implicit one, and a mismatch would split the taskbar button from the shortcut.
assert!(!WXS.contains("System.AppUserModel.ID"));
}
}

View File

@ -5,7 +5,7 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::time::{Duration, Instant};
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
@ -83,18 +83,20 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
let deadline = Instant::now() + Duration::from_secs(60);
let (mut req, rrx) = ControlRequest::new(method, params);
req.deadline = Some(deadline);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let mut r = rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
};
if write_reply(&mut out, &reply).is_err() {
break;
@ -124,6 +126,7 @@ mod tests {
let handler = std::thread::spawn(move || {
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(first.method, "test.large");
assert!(first.deadline.is_some_and(|deadline| deadline > std::time::Instant::now()), "native requests carry their reply deadline");
first.reply.send(json!({"ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)})).unwrap();
let second = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(second.method, "test.small");
@ -154,4 +157,46 @@ mod tests {
handler.join().unwrap();
server.join().unwrap();
}
#[test]
fn bad_json_reply_carries_a_null_id_and_the_connection_keeps_serving() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(req.method, "test.after");
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
writeln!(stream, "{}", json!({"id": "auth", "method": "auth", "params": {"token": TOKEN}})).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["ok"], true);
writeln!(stream, "{{not json}}").unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let bad: Value = serde_json::from_str(&line).unwrap();
let bad = bad.as_object().unwrap();
assert_eq!(bad.get("id"), Some(&Value::Null), "bad JSON reply must carry `id: null`: {line}");
assert_eq!(bad.get("ok"), Some(&Value::Bool(false)));
assert!(bad.get("error").and_then(Value::as_str).unwrap().starts_with("bad JSON: "));
writeln!(stream, "{}", json!({"id": 7, "method": "test.after"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let after: Value = serde_json::from_str(&line).unwrap();
assert_eq!(after["id"], 7);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}

View File

@ -0,0 +1,126 @@
//! Where the pointer is, read from the OS, so a file drop can tell the canvas (place as a layer)
//! from the tab strip (open at that tab position) and the panels (open as a document), and the tab
//! strip can show where a dragged file would go. winit 0.30's file drops carry no position, and
//! during an OS drag the window gets no pointer events, so egui's last pointer is stale.
//!
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
//!
//! No `unsafe`: every OS call goes through a safe binding.
//!
//! [`service`] builds the pointer-position service for the app's window, `None` where it can't
//! be read.
pub use platform::service;
#[cfg(target_os = "macos")]
mod platform {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEvent, NSWindow};
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
Some(Box::new(|ctx: &egui::Context| in_window_points().map(|(x, y)| egui::pos2(x as f32, y as f32) / ctx.zoom_factor())))
}
/// The pointer in points from the top-left of the content view of our frontmost window under
/// it (`None` when it's over none of ours, or off the main thread). Only our own windows are
/// searched: a drop that reached us landed on one of them.
fn in_window_points() -> Option<(f64, f64)> {
let mtm = MainThreadMarker::new()?;
let at = NSEvent::mouseLocation();
let contains = |w: &NSWindow| {
let f = w.frame();
(f.origin.x..f.origin.x + f.size.width).contains(&at.x) && (f.origin.y..f.origin.y + f.size.height).contains(&at.y)
};
let window = NSApplication::sharedApplication(mtm).orderedWindows().iter().find(|w| w.isVisible() && contains(w))?;
let view = window.contentView()?;
let p = view.convertPoint_fromView(window.convertPointFromScreen(at), None);
let y = if view.isFlipped() { p.y } else { view.bounds().size.height - p.y };
Some((p.x, y))
}
}
#[cfg(target_os = "linux")]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::CursorPosFn;
use x11rb::protocol::xproto::ConnectionExt;
use x11rb::rust_connection::RustConnection;
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
let window = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok()?,
RawWindowHandle::Xcb(h) => h.window.get(),
_ => return None,
};
// Opened on first use and kept: the pointer is read every frame while files hover.
let mut conn: Option<RustConnection> = None;
Some(Box::new(move |ctx: &egui::Context| match pointer_in(&mut conn, window) {
Ok(p) => p.map(|(x, y)| egui::pos2(f32::from(x), f32::from(y)) / ctx.pixels_per_point()),
Err(e) => {
log::warn!("couldn't read the pointer position: {e}");
conn = None;
None
}
}))
}
/// The pointer in pixels relative to `window` (`None` when it's on another screen).
fn pointer_in(conn: &mut Option<RustConnection>, window: u32) -> Result<Option<(i16, i16)>, String> {
let conn = match conn {
Some(c) => c,
None => conn.insert(RustConnection::connect(None).map_err(|e| e.to_string())?.0),
};
let reply = conn.query_pointer(window).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
Ok(reply.same_screen.then_some((reply.win_x, reply.win_y)))
}
}
#[cfg(target_os = "windows")]
mod platform {
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
Some(Box::new(|ctx: &egui::Context| {
let pt = winsafe::GetCursorPos().map_err(|e| log::warn!("couldn't read the pointer position: {e}")).ok()?;
let inner = ctx.input(|i| i.viewport().inner_rect)?;
Some(super::client_points(egui::pos2(pt.x as f32, pt.y as f32), inner.min, ctx.pixels_per_point()))
}))
}
}
/// A screen position in physical pixels to points in the window whose client area starts at
/// `inner_min` (egui's `inner_rect.min`: the client origin in pixels over `ppp`). On Windows
/// `GetCursorPos` and the client origin are both physical pixels (winit makes the process
/// per-monitor DPI aware) and the window draws at one scale wherever it sits, so this holds across
/// monitors with different DPIs.
#[cfg(any(target_os = "windows", test))]
fn client_points(screen_px: egui::Pos2, inner_min: egui::Pos2, ppp: f32) -> egui::Pos2 {
((screen_px - inner_min * ppp) / ppp).to_pos2()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
mod platform {
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
None
}
}
#[cfg(test)]
mod tests {
use egui::pos2;
#[test]
fn client_points_on_a_second_monitor_at_another_scale() {
// A 150% monitor left of a 100% primary: the window's client area starts at (-2400, 300) px.
let inner_min = pos2(-2400.0, 300.0) / 1.5;
assert_eq!(super::client_points(pos2(-2100.0, 450.0), inner_min, 1.5), pos2(200.0, 100.0));
// The same window on the 100% primary.
assert_eq!(super::client_points(pos2(500.0, 400.0), pos2(300.0, 300.0), 1.0), pos2(200.0, 100.0));
}
}

View File

@ -0,0 +1,772 @@
//! Crash-safe GPU startup (#4).
//!
//! A graphics driver that segfaults while the window's wgpu device is created (the Intel Vulkan
//! driver `igvk64.dll` on Windows) can't be caught in-process. So before the device exists the app
//! writes a *marker* (`gpu-starting.json` in the config directory: backend and, once chosen, the
//! adapter) and holds an OS file lock on it; it clears the marker once the first frames have
//! rendered. If the next launch finds a marker that no running process holds, the previous start
//! died inside the driver, and this one retries with the next safer backend:
//!
//! - Windows: Vulkan → DX12 → CPU
//! - Linux and other Unix: Vulkan → GL → CPU
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use eframe::{egui_wgpu, wgpu};
use photocraft_engine::prefs::{GpuBackend, RenderingMode};
use serde_json::{Value, json};
/// The marker's file name in the config directory.
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
/// Platform families with different backend chains.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Os {
Windows,
Mac,
/// Linux, the BSDs and other Unix.
Other,
}
impl Os {
pub fn current() -> Self {
if cfg!(windows) {
Os::Windows
} else if cfg!(target_os = "macos") {
Os::Mac
} else {
Os::Other
}
}
}
/// What a start records before touching the driver.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Marker {
/// The plan's backend (`auto`, `dx12`, …), or `env:<value>` under `WGPU_BACKEND`.
pub backend: String,
/// The chosen adapter's name, backend and driver (empty until the adapter is chosen).
pub adapter: String,
pub adapter_backend: String,
pub driver: String,
pub version: String,
}
impl Marker {
pub fn to_json(&self) -> String {
json!({
"backend": self.backend,
"adapter": self.adapter,
"adapterBackend": self.adapter_backend,
"driver": self.driver,
"version": self.version,
})
.to_string()
}
/// Parse a marker leniently: anything unreadable is a marker of an unknown backend.
pub fn parse(text: &str) -> Self {
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").chars().take(256).collect::<String>();
Marker { backend: s("backend"), adapter: s("adapter"), adapter_backend: s("adapterBackend"), driver: s("driver"), version: s("version") }
}
/// The backend the crashed start was using: the adapter's when it got that far with `auto`,
/// else the plan's. `None` for a start under `WGPU_BACKEND` (the user's own override).
pub fn tried(&self) -> Option<GpuBackend> {
let planned = if self.backend.is_empty() { Some(GpuBackend::Auto) } else { GpuBackend::parse(&self.backend) }?;
match (planned, GpuBackend::parse(&self.adapter_backend)) {
(GpuBackend::Auto, Some(actual)) => Some(actual),
_ => Some(planned),
}
}
}
/// The backend this launch uses and why.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Plan {
pub backend: GpuBackend,
/// `WGPU_BACKEND`, which picks the backends itself.
pub env: Option<String>,
/// Why this isn't simply the preference.
pub reason: Option<String>,
/// Store `backend` in the preferences once the start succeeded (a crash fallback).
pub remember: bool,
}
/// A backend the platform has (others fall back to `auto`).
pub fn sanitize(pref: GpuBackend, os: Os) -> GpuBackend {
use GpuBackend::*;
match (os, pref) {
(Os::Windows, Metal) | (Os::Mac, Vulkan | Dx12 | Gl) | (Os::Other, Dx12 | Metal) => Auto,
(_, b) => b,
}
}
/// The next safer backend after a start on `tried` crashed.
pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
use GpuBackend::*;
match (os, tried) {
(Os::Windows, Auto | Vulkan | Gl | Metal) => Dx12,
(Os::Windows, Dx12 | Cpu) => Cpu,
(Os::Mac, _) => Cpu,
(Os::Other, Auto | Vulkan | Dx12 | Metal) => Gl,
(Os::Other, Gl | Cpu) => Cpu,
}
}
/// Whether a start that returned an error (so it didn't crash in the driver) keeps its marker, so
/// the next start tries a safer backend. Not when there is none: the CPU path is the end of every
/// chain, and a marker left there pinned every later start to the same failing path. Not under
/// `WGPU_BACKEND` either (no plan reads that marker).
pub fn keep_marker_after_error(plan: &Plan, os: Os) -> bool {
plan.env.is_none() && next_safer(plan.backend, os) != plan.backend
}
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
/// `WGPU_BACKEND` and `--safe-gpu`.
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
let backend = if safe_gpu { GpuBackend::Cpu } else { GpuBackend::Auto };
return Plan { backend, env: Some(v.to_string()), reason: Some(format!("WGPU_BACKEND={v} overrides the backend")), remember: false };
}
if safe_gpu {
return Plan { backend: GpuBackend::Cpu, env: None, reason: Some("--safe-gpu: CPU renderer for this launch".into()), remember: false };
}
let pref = sanitize(pref, os);
if let Some((m, tried)) = crashed.and_then(|m| Some((m, m.tried()?))) {
let next = next_safer(tried, os);
let on = if m.adapter.is_empty() { String::new() } else { format!(" ({})", m.adapter) };
return Plan {
backend: next,
env: None,
reason: Some(format!("the previous start didn't finish on {}{on}; using {}", tried.name(), next.name())),
remember: next != pref,
};
}
Plan { backend: pref, env: None, reason: None, remember: false }
}
/// Resolve the rendering policy before the window exists. Explicit GPU/Automatic selections
/// reset a legacy CPU backend to automatic; CPU keeps software-preferred window presentation.
pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
let backend = match mode {
RenderingMode::Cpu => GpuBackend::Cpu,
RenderingMode::Auto | RenderingMode::Gpu if pref == GpuBackend::Cpu => GpuBackend::Auto,
_ => pref,
};
let env = if safe_gpu || mode == RenderingMode::Cpu { None } else { env };
let crashed = if mode == RenderingMode::Cpu { None } else { crashed };
plan(backend, crashed, env, safe_gpu, os)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
return None;
}
Some(match plan.backend {
GpuBackend::Auto => return None,
GpuBackend::Vulkan => wgpu::Backends::VULKAN,
GpuBackend::Dx12 => wgpu::Backends::DX12,
GpuBackend::Metal => wgpu::Backends::METAL,
GpuBackend::Gl => wgpu::Backends::GL,
GpuBackend::Cpu => match os {
Os::Windows => wgpu::Backends::DX12,
Os::Mac => wgpu::Backends::METAL,
Os::Other => wgpu::Backends::GL,
},
})
}
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
/// compile. Use a `dxcompiler.dll` shipped beside the executable, by its full path; otherwise FXC
/// (`d3dcompiler_47.dll`, part of Windows). `WGPU_DX12_COMPILER` still picks one explicitly.
pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Compiler {
if let Some(c) = env.and_then(|v| v.trim().parse().ok()) {
return c;
}
let beside_exe = exe_dir.map(|d| d.join("dxcompiler.dll")).filter(|p| p.is_file());
match beside_exe.as_deref().and_then(Path::to_str) {
Some(p) => wgpu::Dx12Compiler::DynamicDxc { dxc_path: p.to_string() },
None => wgpu::Dx12Compiler::Fxc,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
pub vendor: u32,
pub device_type: wgpu::DeviceType,
pub backend: wgpu::Backend,
/// Can present to the window.
pub surface_ok: bool,
}
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows.
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
wgpu::DeviceType::VirtualGpu => 2,
wgpu::DeviceType::Other => 3,
wgpu::DeviceType::Cpu => 4,
};
let ty = match (backend, a.device_type) {
(GpuBackend::Cpu, wgpu::DeviceType::Cpu) => 0,
(GpuBackend::Cpu, _) => ty + 1,
_ => ty,
};
let be = match a.backend {
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
};
(ty, be)
}
/// The adapter to use among `adapters` (`None` when there is none).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
}
if os == Os::Windows {
let exe = std::env::current_exe().ok();
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
if select {
let backend = plan.backend;
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
let cands: Vec<Candidate> = adapters
.iter()
.map(|a| {
let i = a.get_info();
Candidate {
vendor: i.vendor,
device_type: i.device_type,
backend: i.backend,
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
}
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
}
}
photocraft_ui_egui::gpu_canvas::use_adapter_limits_with(setup, move |adapter| {
let info = adapter.get_info();
let mut guard = sentinel.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(s) = guard.as_mut() {
let mut m = s.marker.clone();
m.adapter = info.name.clone();
m.adapter_backend = photocraft_ui_egui::gpu_canvas::backend_name(info.backend).to_string();
m.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
if let Err(e) = s.write(m) {
log::warn!("GPU startup marker: {e}");
}
}
});
}
/// What a previous start left behind.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Previous {
/// Nothing: it finished starting (or never ran).
Clean,
/// Its marker, unlocked: it died before rendering its first frames.
Crashed(Marker),
/// Another instance is starting right now (it holds the lock).
Busy,
}
impl Previous {
pub fn crashed(&self) -> Option<&Marker> {
match self {
Previous::Crashed(m) => Some(m),
_ => None,
}
}
}
/// The locked marker file of this start.
#[derive(Debug)]
pub struct Sentinel {
path: PathBuf,
file: Option<File>,
marker: Marker,
}
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
/// Only renderer errors should affect the next launch's graphics backend. Windowing errors
/// (such as a missing Linux display) and app setup errors never indicate a driver failure.
pub fn keep_marker_after_run(result: &eframe::Result) -> bool {
matches!(result, Err(eframe::Error::Wgpu(_)))
}
impl Sentinel {
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
/// unless another instance holds the lock or the directory isn't writable, this start's
/// sentinel (not yet written: see [`Sentinel::write`]).
pub fn begin(dir: &Path) -> (Previous, Option<Sentinel>) {
let path = dir.join(MARKER_FILE);
if let Err(e) = std::fs::create_dir_all(dir) {
log::warn!("GPU startup marker: {e}");
return (Previous::Clean, None);
}
let mut file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
Ok(f) => f,
Err(e) => {
log::warn!("GPU startup marker {}: {e}", path.display());
return (Previous::Clean, None);
}
};
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return (Previous::Busy, None),
Err(std::fs::TryLockError::Error(e)) => {
log::warn!("GPU startup marker lock: {e}");
return (Previous::Clean, None);
}
}
let mut text = String::new();
let _ = (&mut file).take(MARKER_MAX).read_to_string(&mut text);
let previous = if text.trim().is_empty() { Previous::Clean } else { Previous::Crashed(Marker::parse(&text)) };
(previous, Some(Sentinel { path, file: Some(file), marker: Marker::default() }))
}
/// Replace the marker's contents with `m`.
pub fn write(&mut self, m: Marker) -> std::io::Result<()> {
let f = self.file.as_mut().ok_or_else(|| std::io::Error::other("marker closed"))?;
f.set_len(0)?;
f.seek(SeekFrom::Start(0))?;
f.write_all(m.to_json().as_bytes())?;
f.flush()?;
self.marker = m;
Ok(())
}
/// The start succeeded: clear the marker and release the lock.
pub fn finish(mut self) {
if let Some(f) = self.file.take() {
// Empty means "no crash" even if removing fails (another instance has it open).
let _ = f.set_len(0);
drop(f);
}
let _ = std::fs::remove_file(&self.path);
}
}
/// The Linux display-server preference (`performance.linuxDisplayServer`) from the preferences
/// file, read before the window opens; `Auto` when the file or value is missing or unknown.
#[cfg(any(target_os = "linux", test))]
pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
v.get("performance")
.and_then(|p| serde_json::from_value::<photocraft_engine::prefs::Performance>(p.clone()).ok())
.map(|performance| performance.linux_display_server)
.unwrap_or_default()
}
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let perf = v.get("performance");
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
let explicit = perf.and_then(|p| p.get("renderingMode")).and_then(Value::as_str).and_then(RenderingMode::parse);
let mode = explicit.unwrap_or(if !use_gpu || backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto });
(backend, mode)
}
#[cfg(test)]
mod tests {
use super::*;
use GpuBackend::*;
fn crashed(backend: &str, adapter_backend: &str) -> Marker {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
let dir = std::env::temp_dir().join(format!("photocraft-display-server-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_display_server(Some(&file))
};
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11"}}"#), LinuxDisplayServer::X11);
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"auto","gpuBackend":"vulkan"}}"#), LinuxDisplayServer::Auto);
// Unknown values, other types, broken JSON and a missing file all start as Auto.
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto);
assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto);
assert_eq!(read("{not json"), LinuxDisplayServer::Auto);
assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto);
assert_eq!(read_display_server(None), LinuxDisplayServer::Auto);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cpu_policy_ignores_hardware_backend_override() {
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
assert_eq!(p.backend, Cpu);
assert!(p.env.is_none());
assert_eq!(plan_with_mode(Vulkan, RenderingMode::Cpu, Some(&crashed("vulkan", "vulkan")), None, false, Os::Windows).backend, Cpu);
let p = plan_with_mode(Auto, RenderingMode::Gpu, None, Some("vulkan"), true, Os::Windows);
assert_eq!(p.backend, Cpu);
assert!(p.env.is_none());
}
#[test]
fn no_marker_uses_the_preference() {
for os in [Os::Windows, Os::Mac, Os::Other] {
let p = plan(Auto, None, None, false, os);
assert_eq!(p, Plan { backend: Auto, env: None, reason: None, remember: false });
}
assert_eq!(plan(Dx12, None, None, false, Os::Windows).backend, Dx12);
assert_eq!(plan(Gl, None, None, false, Os::Other).backend, Gl);
// A backend the platform lacks is `auto`.
assert_eq!(plan(Metal, None, None, false, Os::Windows).backend, Auto);
assert_eq!(plan(Dx12, None, None, false, Os::Mac).backend, Auto);
assert_eq!(plan(Dx12, None, None, false, Os::Other).backend, Auto);
}
#[test]
fn a_crash_moves_down_each_chain_and_is_remembered() {
// Windows: Vulkan → DX12 → CPU.
let p = plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows);
assert_eq!((p.backend, p.remember), (Dx12, true));
assert!(p.reason.as_deref().unwrap_or("").contains("vulkan (Intel(R) UHD Graphics)"), "{p:?}");
assert_eq!(plan(Dx12, Some(&crashed("dx12", "dx12")), None, false, Os::Windows).backend, Cpu);
// Crashed before the adapter was chosen (enumeration): from `auto`.
assert_eq!(plan(Auto, Some(&crashed("auto", "")), None, false, Os::Windows).backend, Dx12);
// `auto` that had already picked DX12 (the Intel default) goes to the CPU.
assert_eq!(plan(Auto, Some(&crashed("auto", "dx12")), None, false, Os::Windows).backend, Cpu);
// Linux: Vulkan → GL → CPU.
assert_eq!(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Other).backend, Gl);
assert_eq!(plan(Gl, Some(&crashed("gl", "gl")), None, false, Os::Other).backend, Cpu);
// macOS: Metal → CPU.
assert_eq!(plan(Auto, Some(&crashed("auto", "metal")), None, false, Os::Mac).backend, Cpu);
// The CPU path stays there, and isn't "remembered" again.
let p = plan(Cpu, Some(&crashed("cpu", "dx12")), None, false, Os::Windows);
assert_eq!((p.backend, p.remember), (Cpu, false));
// An unreadable marker still counts as a crash.
assert_eq!(plan(Auto, Some(&Marker::parse("{garbage")), None, false, Os::Windows).backend, Dx12);
}
#[test]
fn env_override_beats_everything() {
let p = plan(Cpu, Some(&crashed("auto", "vulkan")), Some("vulkan"), false, Os::Windows);
assert_eq!(p.env.as_deref(), Some("vulkan"));
assert_eq!(p.backend, Auto);
assert!(!p.remember);
assert_eq!(backends(&p, Os::Windows), None, "egui reads WGPU_BACKEND itself");
// A crash under the override doesn't count against `auto` later.
let p = plan(Auto, Some(&Marker { backend: "env:vulkan".into(), ..Default::default() }), None, false, Os::Windows);
assert_eq!(p.backend, Auto);
// Empty means unset.
assert_eq!(plan(Auto, None, Some(" "), false, Os::Windows).env, None);
// --safe-gpu with the override: CPU canvas, the override's backends.
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
assert_eq!((p.backend, p.remember), (Cpu, false));
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL));
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
Candidate { vendor, device_type, backend, surface_ok: true }
}
#[test]
fn intel_on_windows_defaults_to_dx12() {
use wgpu::{Backend as B, DeviceType as T};
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert!(intel_dx12_applied(&intel, i, Os::Windows));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
// Adapters that can't present lose to ones that can.
let mut only = [cand(0x10de, T::DiscreteGpu, B::Vulkan), intel[1]];
only[0].surface_ok = false;
assert_eq!(pick(&only, Auto, Os::Windows), Some(1));
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
let a = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(0x1414, T::Cpu, B::Dx12)];
assert_eq!(pick(&a, Cpu, Os::Windows), Some(1));
// No software adapter: still something.
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
a.surface_ok = false;
assert_eq!(pick(&[a], Auto, Os::Windows), None);
assert_eq!(pick(&[a], Cpu, Os::Windows), None);
}
#[test]
fn explicit_policy_overrides_legacy_cpu_backend() {
assert_eq!(plan_with_mode(Cpu, RenderingMode::Gpu, None, None, false, Os::Mac).backend, Auto);
assert_eq!(plan_with_mode(Metal, RenderingMode::Cpu, None, None, false, Os::Mac).backend, Cpu);
assert_eq!(plan_with_mode(Auto, RenderingMode::Gpu, None, None, true, Os::Mac).backend, Cpu);
}
#[test]
fn marker_round_trip_and_lenient_parse() {
let m = Marker {
backend: "auto".into(),
adapter: "A \"quoted\" GPU".into(),
adapter_backend: "vulkan".into(),
driver: "1.2".into(),
version: "0.1".into(),
};
assert_eq!(Marker::parse(&m.to_json()), m);
assert_eq!(Marker::parse(""), Marker::default());
assert_eq!(Marker::parse("[1,2]").tried(), Some(Auto));
assert_eq!(Marker::parse(r#"{"backend": 7}"#).tried(), Some(Auto));
assert_eq!(Marker::parse(r#"{"backend": "env:dx12"}"#).tried(), None);
let long = format!(r#"{{"adapter": "{}"}}"#, "x".repeat(10_000));
assert_eq!(Marker::parse(&long).adapter.len(), 256);
}
#[test]
fn a_failed_start_keeps_its_marker_only_when_a_safer_backend_exists() {
// GPU backends move down the chain on the next start.
assert!(keep_marker_after_error(&plan(Auto, None, None, false, Os::Other), Os::Other));
assert!(keep_marker_after_error(&plan(Gl, None, None, false, Os::Other), Os::Other));
assert!(keep_marker_after_error(&plan(Dx12, None, None, false, Os::Windows), Os::Windows));
assert!(keep_marker_after_error(&plan(Auto, None, None, false, Os::Mac), Os::Mac));
// The CPU path is the end of every chain, including the `--safe-gpu` retry.
for os in [Os::Windows, Os::Mac, Os::Other] {
assert!(!keep_marker_after_error(&plan(Cpu, None, None, false, os), os));
assert!(!keep_marker_after_error(&plan(Auto, None, None, true, os), os));
}
// `WGPU_BACKEND` is the user's choice; no plan reads its marker.
assert!(!keep_marker_after_error(&plan(Auto, None, Some("vulkan"), false, Os::Other), Os::Other));
}
/// A failed CPU start (no usable GL adapter under XWayland) left a `cpu` marker, so every
/// later start was planned on CPU and failed the same way until the marker was deleted by hand.
#[test]
fn a_failed_cpu_start_does_not_pin_later_starts() {
let dir = temp_dir("cpu-error");
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
let failed = plan(Auto, None, None, true, Os::Other);
s.write(Marker { backend: failed.backend.name().into(), ..Default::default() }).unwrap();
// eframe returned an error: what `main` does with the marker.
if keep_marker_after_error(&failed, Os::Other) {
drop(s);
} else {
s.finish();
}
let (prev, _s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
let _ = std::fs::remove_dir_all(&dir);
}
fn temp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn sentinel_detects_a_crash_and_clears_on_success() {
let dir = temp_dir("cycle");
// First launch: nothing left behind.
let (prev, s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "auto".into(), ..Default::default() }).unwrap();
s.write(Marker { backend: "auto".into(), adapter: "GPU".into(), adapter_backend: "vulkan".into(), ..Default::default() }).unwrap();
// While it is starting, a second instance sees it busy, not crashed.
let (busy, none) = Sentinel::begin(&dir);
assert_eq!(busy, Previous::Busy);
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
// Another test in this binary spawns a process: between its fork and exec the child briefly
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
let (mut prev, mut s) = Sentinel::begin(&dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(&dir);
}
let m = prev.crashed().cloned().expect("crash detected");
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
// This start succeeds: the next one finds nothing.
let mut s = s.expect("sentinel");
s.write(Marker { backend: "dx12".into(), ..Default::default() }).unwrap();
s.finish();
assert!(!dir.join(MARKER_FILE).exists());
let (prev, _s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
("success", Ok(()), false),
("app-error", Err(eframe::Error::AppCreation(Box::new(std::io::Error::other("app setup failed")))), false),
("gpu-error", Err(eframe::Error::Wgpu(egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no adapter".into()))), true),
];
for (name, result, keep) in cases {
let dir = temp_dir(name);
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
if keep_marker_after_run(&result) {
drop(s);
} else {
s.finish();
}
let (previous, s) = Sentinel::begin(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
}
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn unwritable_config_dir_is_not_fatal() {
let dir = temp_dir("file");
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
std::fs::write(&dir, b"not a directory").unwrap();
let (prev, s) = Sentinel::begin(&dir.join("sub"));
assert_eq!(prev, Previous::Clean);
assert!(s.is_none());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn prefs_are_read_leniently() {
let dir = temp_dir("prefs");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("preferences.json");
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
std::fs::write(&p, r#"{"performance": {"useGpu": false, "renderingMode": "gpu"}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Gpu));
std::fs::write(&p, "{not json").unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
let _ = std::fs::remove_dir_all(&dir);
}
/// #712: never a `dxcompiler.dll` found through the DLL search path (current directory, PATH).
#[test]
fn dx12_compiler_is_fxc_or_dxc_beside_the_exe() {
use wgpu::Dx12Compiler as C;
let dir = temp_dir("dxc");
std::fs::create_dir_all(&dir).unwrap();
assert!(matches!(dx12_compiler(None, None), C::Fxc));
assert!(matches!(dx12_compiler(None, Some(&dir)), C::Fxc));
std::fs::write(dir.join("dxcompiler.dll"), b"MZ").unwrap();
let expected = dir.join("dxcompiler.dll");
assert!(matches!(dx12_compiler(None, Some(&dir)), C::DynamicDxc { dxc_path } if Path::new(&dxc_path) == expected));
// An explicit WGPU_DX12_COMPILER wins; an unknown value is ignored.
assert!(matches!(dx12_compiler(Some(" FXC "), Some(&dir)), C::Fxc));
assert!(matches!(dx12_compiler(Some("dxc"), None), C::DynamicDxc { dxc_path } if dxc_path == "dxcompiler.dll"));
assert!(matches!(dx12_compiler(Some("quantum"), None), C::Fxc));
let _ = std::fs::remove_dir_all(&dir);
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
C::Fxc => true,
C::DynamicDxc { dxc_path } => Path::new(dxc_path).is_absolute(),
C::StaticDxc | C::Auto => false,
};
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
}
}
}

View File

@ -0,0 +1,423 @@
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
//!
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
//! before eframe starts, we look for the libraries the current session type needs and, if one is
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
//! crash).
//!
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
//!
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
//! and [`available`] touch the system, and they are only called on Linux.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::{Path, PathBuf};
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplaySession {
Wayland,
X11,
/// Neither is set: winit reports that itself (an error, not a panic).
None,
}
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
DisplaySession::Wayland
} else if set("DISPLAY") {
DisplaySession::X11
} else {
DisplaySession::None
}
}
/// A runtime-loaded library and the package that provides it on each distro family.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lib {
pub soname: &'static str,
pub debian: &'static str,
pub fedora: &'static str,
pub arch: &'static str,
pub suse: &'static str,
}
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
Lib { soname, debian, fedora, arch, suse }
}
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
/// One requirement: at least one of these libraries must be present.
pub type AnyOf = &'static [Lib];
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
const GPU: AnyOf = &[VULKAN, EGL];
match session {
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
DisplaySession::None => Vec::new(),
}
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let line = line.trim_start();
let Some((name, rest)) = line.split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// A real `ldconfig -p` listing starts with "N libs found in cache".
fn ldconfig_output_is_valid(output: &str) -> bool {
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
}
/// Where we look for libraries, and what we found there.
pub struct Probe {
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
pub ldconfig: Option<String>,
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
pub dirs: Vec<PathBuf>,
}
impl Probe {
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
}
}
/// The outcome of the check.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Ok,
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
/// cache was read, so the libraries really are absent (and winit would fail or panic).
Missing {
missing: Vec<AnyOf>,
conclusive: bool,
},
}
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
}
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distro {
Debian,
Fedora,
Arch,
Suse,
Unknown,
}
pub fn distro_from_os_release(text: &str) -> Distro {
let mut ids = Vec::new();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else { continue };
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
}
}
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
if any(&["debian", "ubuntu"]) {
Distro::Debian
} else if any(&["fedora", "rhel", "centos"]) {
Distro::Fedora
} else if any(&["arch"]) {
Distro::Arch
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
Distro::Suse
} else {
Distro::Unknown
}
}
fn package(lib: &Lib, distro: Distro) -> &'static str {
match distro {
Distro::Debian | Distro::Unknown => lib.debian,
Distro::Fedora => lib.fedora,
Distro::Arch => lib.arch,
Distro::Suse => lib.suse,
}
}
fn install_command(packages: &[&str], distro: Distro) -> String {
let list = packages.join(" ");
match distro {
Distro::Debian => format!("sudo apt install {list}"),
Distro::Fedora => format!("sudo dnf install {list}"),
Distro::Arch => format!("sudo pacman -S {list}"),
Distro::Suse => format!("sudo zypper install {list}"),
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
}
}
/// The message for missing libraries: what's missing, and the command that installs it.
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
let session_name = match session {
DisplaySession::Wayland => "Wayland",
DisplaySession::X11 => "X11",
DisplaySession::None => "display",
};
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
// For an either/or requirement, suggest its first alternative.
let mut packages: Vec<&str> = Vec::new();
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
if !packages.contains(&p) {
packages.push(p);
}
}
let mut msg = if conclusive {
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
} else {
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
};
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
if distro == Distro::Unknown {
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
}
if conclusive {
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
}
msg
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
dirs.push(PathBuf::from(d));
}
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
fn read_ldconfig() -> Option<String> {
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
&& out.status.success()
{
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if ldconfig_output_is_valid(&text) {
return Some(text);
}
}
}
None
}
/// This system's linker cache and library directories.
fn system_probe() -> Probe {
Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) }
}
/// Are all the libraries `session` needs found on this system? (For choosing a display server:
/// libraries the check can't find count as missing.)
pub fn available(session: DisplaySession) -> bool {
check(session, &system_probe(), |p| p.exists()) == Verdict::Ok
}
/// Run the check for the display server the window opens on against this system. `Err` carries
/// the message to print before exiting with a non-zero status; a warning is printed here and the
/// app starts.
pub fn preflight(session: DisplaySession) -> Result<(), String> {
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
return Ok(());
}
if session == DisplaySession::None {
return Ok(());
}
match check(session, &system_probe(), |p| p.exists()) {
Verdict::Ok => Ok(()),
Verdict::Missing { missing, conclusive } => {
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
let msg = missing_message(session, &missing, distro, conclusive);
if conclusive {
Err(msg)
} else {
eprint!("{msg}");
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| map.get(k).cloned()
}
#[test]
fn session_follows_winit_order() {
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
}
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
assert!(ldconfig_output_is_valid(CACHE));
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
// Prefix of another name, or a missing name, is not a match.
assert!(!ldconfig_has(CACHE, "libX11.so", None));
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
assert!(!ldconfig_has("", "libX11.so.6", None));
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
}
fn cache_for_this_arch(names: &[&str]) -> String {
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
for n in names {
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
}
s
}
const ALL_X11: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
#[test]
fn x11_without_xkbcommon_x11_is_reported() {
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
let v = check(DisplaySession::X11, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
assert!(conclusive);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
}
#[test]
fn complete_systems_pass() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
// Wayland doesn't need the X11 libraries; no session needs nothing.
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
}
#[test]
fn gpu_needs_vulkan_or_egl() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
assert_eq!(missing.len(), 1);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
}
#[test]
fn library_dirs_count_and_unread_cache_only_warns() {
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
// Found by file in a directory (no ldconfig, as on NixOS).
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
let v = check(DisplaySession::Wayland, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
assert!(!conclusive);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
assert!(msg.contains("Starting anyway"), "{msg}");
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
}
#[test]
fn distro_detection() {
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
assert_eq!(distro_from_os_release(""), Distro::Unknown);
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
}
}

View File

@ -0,0 +1,432 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU startup fallbacks, a
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/photocraft.log`
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
//! `.2`), so the log of a run that crashed survives the next launch.
//!
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,photocraft_gpu=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`photocraft*=debug`).
//!
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "photocraft.log";
/// How many previous logs are kept (`photocraft.1.log` … `photocraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,photocraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let thread = std::thread::current();
let line = format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("photocraft"), LevelFilter::Info);
assert_eq!(f.level_for("photocraft::gpu_startup"), LevelFilter::Info);
assert_eq!(f.level_for("photocraft_gpu::health"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,photocraft_gpu=trace,photocraft_gpu::health=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("photocraft_gpu::canvas"), LevelFilter::Trace);
assert_eq!(f.level_for("photocraft_gpu::health"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
for spec in ["", ",,,", "=", "=debug", "photocraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,photocraft=DEBUG"] {
let f = Filter::parse(spec);
let _ = f.level_for("photocraft");
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,photocraft=DEBUG").level_for("photocraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
// 2026-10-08T07:59:17.728Z
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4");
assert_eq!(std::fs::read_to_string(dir.join("photocraft.1.log")).expect("1"), "run 3");
assert_eq!(std::fs::read_to_string(dir.join("photocraft.2.log")).expect("2"), "run 2");
assert!(!dir.join("photocraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "photocraft::gpu_startup", "GPU startup marker: denied"),
"2026-10-08T07:59:17.728Z WARN [main] photocraft::gpu_startup: GPU startup marker: denied\n"
);
// A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "photocraft::app_dirs", "settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Debug, "photocraft", "below info");
let text = read(&path);
assert!(text.contains(" INFO [") && text.contains("photocraft::app_dirs: settings directory\n"), "{text}");
assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}");
assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("photocraft_gpu").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
let _ = std::fs::remove_file(&dir);
}
}

View File

@ -0,0 +1,359 @@
//! The macOS menu bar, through muda (a standalone native menu crate, not Tauri: no webview, just
//! AppKit menus behind a safe API, no GTK on macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in
//! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
//!
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
//! gets `id#2` for its second copy, as muda ids must be unique.
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
//! else (labels, enabled, checked) is updated in place.
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, channel};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
use photocraft_ui_egui::native_menu::{self, Backend, Chord, Event, MenuBar, MenuRole, Node, Standard};
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
/// One native item.
struct Entry {
handle: Handle,
/// The command id (several entries can share one).
command: String,
chord: Option<Chord>,
label: String,
enabled: bool,
}
/// How an item was chosen, read from AppKit's current event while it calls the handler.
struct Chosen {
id: String,
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
key: bool,
mods: [bool; 4],
}
pub struct MacMenu {
menu: Option<Menu>,
/// By muda id.
entries: HashMap<String, Entry>,
rx: Receiver<Chosen>,
structure: u64,
}
impl MacMenu {
pub fn new(ctx: &egui::Context) -> MacMenu {
let (tx, rx) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
// AppKit calls this on the main thread, from the event that chose the item: a key
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
let flags = event.as_ref().map(|ev| ev.modifierFlags()).unwrap_or(NSEventModifierFlags::empty());
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
.map(|m| flags.contains(m));
let _ = tx.send(Chosen { id: e.id.0, key, mods });
// Wake egui, so the item runs now rather than on the next mouse move.
repaint.request_repaint();
}));
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 }
}
fn build(&mut self, bar: &MenuBar) {
if let Some(old) = self.menu.take() {
old.remove_for_nsapp();
}
self.entries.clear();
let menu = Menu::new();
let mut special = Vec::new();
for m in &bar.menus {
let sub = Submenu::new(escape(&m.title), true);
self.append(&sub, &m.children);
let _ = menu.append(&sub);
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
special.push((m.role, sub));
}
}
menu.init_for_nsapp();
// Only once the menu is NSApp's: muda looks the submenus up in the installed main menu.
for (role, sub) in special {
match role {
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
_ => sub.set_as_help_menu_for_nsapp(),
}
}
self.menu = Some(menu);
self.structure = native_menu::structure_key(bar);
}
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
for n in nodes {
match n {
Node::Separator => {
let _ = sub.append(&PredefinedMenuItem::separator());
}
Node::Submenu { label, children, .. } => {
let s = Submenu::new(escape(label), !children.is_empty());
self.append(&s, children);
let _ = sub.append(&s);
}
Node::Standard(item) => {
let p = match item {
Standard::Services => PredefinedMenuItem::services(Some(photocraft_ui_egui::i18n::t("Services"))),
Standard::HideOthers => PredefinedMenuItem::hide_others(Some(photocraft_ui_egui::i18n::t("Hide Others"))),
Standard::ShowAll => PredefinedMenuItem::show_all(Some(photocraft_ui_egui::i18n::t("Show All"))),
Standard::Minimize => PredefinedMenuItem::minimize(None),
Standard::Zoom => PredefinedMenuItem::maximize(Some(photocraft_ui_egui::i18n::t("Zoom"))),
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
Standard::CloseWindow => PredefinedMenuItem::close_window(None),
};
let _ = sub.append(&p);
}
Node::Item(it) => {
let id = self.unique_id(&it.id);
let chord = it.shortcut.as_deref().and_then(Chord::parse);
let accel = chord.as_ref().filter(|c| c.native_ok()).and_then(accelerator);
let handle = match it.checked {
Some(c) => {
let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel);
let _ = sub.append(&h);
Handle::Check(h)
}
None => {
let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel);
let _ = sub.append(&h);
Handle::Plain(h)
}
};
self.entries.insert(id, Entry { handle, command: it.id.clone(), chord, label: it.label.clone(), enabled: it.enabled });
}
}
}
}
/// muda ids must be unique: the second `edit.keyboardShortcuts` becomes `…#2`.
fn unique_id(&self, id: &str) -> String {
let mut unique = id.to_string();
let mut n = 2;
while self.entries.contains_key(&unique) {
unique = format!("{id}#{n}");
n += 1;
}
unique
}
}
impl Backend for MacMenu {
fn sync(&mut self, bar: &MenuBar) {
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure {
self.build(bar);
return;
}
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
for e in self.entries.values_mut() {
let Some(it) = items.get(e.command.as_str()) else { continue };
if e.label != it.label {
let text = escape(&it.label);
match &e.handle {
Handle::Plain(h) => h.set_text(text),
Handle::Check(h) => h.set_text(text),
}
e.label = it.label.clone();
}
if e.enabled != it.enabled {
match &e.handle {
Handle::Plain(h) => h.set_enabled(it.enabled),
Handle::Check(h) => h.set_enabled(it.enabled),
}
e.enabled = it.enabled;
}
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
&& h.is_checked() != c
{
h.set_checked(c);
}
}
}
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(chosen) = self.rx.try_recv() {
// Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled.
let Some(e) = self.entries.get(&chosen.id) else { continue };
if e.command == native_menu::HIDE {
if let Some(mtm) = MainThreadMarker::new() {
NSApplication::sharedApplication(mtm).hide(None);
}
continue;
}
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
// menu (no ⌘ or ⌃, and the item's shortcut needs one). The key is the item's (AppKit
// matched on it); the modifiers are the ones held.
let [cmd, ctrl, alt, shift] = chosen.mods;
let key =
e.chord.as_ref().filter(|c| chosen.key && (cmd || ctrl || !(c.cmd || c.ctrl))).map(|c| Chord { cmd, ctrl, alt, shift, key: c.key.clone() });
out.push(match key {
Some(chord) if e.command != native_menu::MINIMIZE => Event::Key(chord),
_ => Event::Click(e.command.clone()),
});
}
out
}
}
/// Install the menu bar now (in eframe's creator closure, so winit's default menu doesn't stay).
pub fn install(ctx: &egui::Context, app: &photocraft_ui_egui::PhotocraftApp) -> Option<native_menu::NativeMenu> {
let language = &app.session.prefs().interface.language;
let lang = photocraft_ui_egui::i18n::Lang::from_pref(language);
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(app), lang, language).bar;
// Never crash for a menu: if AppKit refuses, keep the in-window menus.
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut menu = MacMenu::new(ctx);
menu.sync(&bar);
menu
}));
match built {
Ok(menu) => Some(native_menu::NativeMenu::new(Box::new(menu))),
Err(_) => {
log::warn!("couldn't install the macOS menu bar; using the in-window menus");
None
}
}
}
/// `&` marks a mnemonic in muda labels.
fn escape(s: &str) -> String {
s.replace('&', "&&")
}
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
fn accelerator(c: &Chord) -> Option<Accelerator> {
let mut mods = Modifiers::empty();
for (on, m) in [(c.cmd, Modifiers::META), (c.ctrl, Modifiers::CONTROL), (c.alt, Modifiers::ALT), (c.shift, Modifiers::SHIFT)] {
if on {
mods |= m;
}
}
const LETTERS: [Code; 26] = [
Code::KeyA,
Code::KeyB,
Code::KeyC,
Code::KeyD,
Code::KeyE,
Code::KeyF,
Code::KeyG,
Code::KeyH,
Code::KeyI,
Code::KeyJ,
Code::KeyK,
Code::KeyL,
Code::KeyM,
Code::KeyN,
Code::KeyO,
Code::KeyP,
Code::KeyQ,
Code::KeyR,
Code::KeyS,
Code::KeyT,
Code::KeyU,
Code::KeyV,
Code::KeyW,
Code::KeyX,
Code::KeyY,
Code::KeyZ,
];
const DIGITS: [Code; 10] =
[Code::Digit0, Code::Digit1, Code::Digit2, Code::Digit3, Code::Digit4, Code::Digit5, Code::Digit6, Code::Digit7, Code::Digit8, Code::Digit9];
const F: [Code; 24] = [
Code::F1,
Code::F2,
Code::F3,
Code::F4,
Code::F5,
Code::F6,
Code::F7,
Code::F8,
Code::F9,
Code::F10,
Code::F11,
Code::F12,
Code::F13,
Code::F14,
Code::F15,
Code::F16,
Code::F17,
Code::F18,
Code::F19,
Code::F20,
Code::F21,
Code::F22,
Code::F23,
Code::F24,
];
let k = c.key.as_str();
let mut chars = k.chars();
let code = match (chars.next(), chars.next()) {
(Some(ch), None) if ch.is_ascii_alphabetic() => *LETTERS.get((ch.to_ascii_uppercase() as usize).checked_sub('A' as usize)?)?,
(Some(ch), None) if ch.is_ascii_digit() => *DIGITS.get((ch as usize).checked_sub('0' as usize)?)?,
_ if c.is_function_key() => *F.get(k.get(1..)?.parse::<usize>().ok()?.checked_sub(1)?)?,
_ => match k {
"[" => Code::BracketLeft,
"]" => Code::BracketRight,
"\\" => Code::Backslash,
"/" => Code::Slash,
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
";" => Code::Semicolon,
"," => Code::Comma,
"." => Code::Period,
"`" => Code::Backquote,
"Delete" | "Backspace" => Code::Backspace,
"Enter" if !mods.is_empty() => Code::Enter,
"Tab" if !mods.is_empty() => Code::Tab,
"Space" if !mods.is_empty() => Code::Space,
_ => return None,
},
};
Some(Accelerator::new(mods, code))
}
#[cfg(test)]
mod tests {
use super::*;
/// Every shortcut the Mac menu shows becomes a key equivalent (or stays egui's, for bare keys).
#[test]
fn every_menu_shortcut_maps_to_a_key_equivalent() {
let app = photocraft_ui_egui::PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
let bar = native_menu::photocraft_layout(&photocraft_ui_egui::menus::menu_items(&app), photocraft_ui_egui::i18n::Lang::EN, "auto").bar;
let mut native = 0;
for it in bar.items() {
let Some(c) = it.shortcut.as_deref().and_then(Chord::parse) else { continue };
if c.native_ok() {
assert!(accelerator(&c).is_some(), "{} ({}) has no key equivalent", c.portable(), it.id);
native += 1;
}
}
assert!(native > 70, "{native} key equivalents");
}
#[test]
fn control_and_command_stay_distinct() {
let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap();
let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap();
assert_ne!(a, b);
}
}

View File

@ -0,0 +1,41 @@
//! macOS window lifecycle: title bar dragging and quitting.
//!
//! Dragging: the window draws under a transparent title bar (`main.rs`), so AppKit would
//! otherwise drag the window from anywhere in that strip, menus included: pressing a menu title
//! and dragging down to an item moved the window instead. Turning AppKit's own dragging off leaves
//! only the app's drag region (the free gap between the menus and the controls,
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
//! `performWindowDragWithEvent:`, which works on an unmovable window).
//!
//! Quitting: see [`terminate_later`]. All safe objc2 calls.
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
/// Stop AppKit dragging the app's windows by their title bar strip.
pub fn disable_native_title_drag() {
let Some(mtm) = MainThreadMarker::new() else {
log::warn!("title bar: not on the main thread; AppKit window dragging left on");
return;
};
for window in NSApplication::sharedApplication(mtm).windows().iter() {
window.setMovable(false);
}
}
/// Quit the way a Mac app does, with `-[NSApplication terminate:]` (`Services::quit`), instead of
/// letting eframe close the window. eframe drops the window while AppKit's run loop is still
/// running, and on Macs with a Touch Bar AppKit's next display cycle then tries to stop observing
/// a view that is gone: an uncaught exception that crashes (macOS 27) or leaves a windowless app
/// that only Force Quit ends (macOS 26) (#1458, #1575). `terminate:` ends the process before
/// another display cycle runs; winit's `applicationWillTerminate:` closes the windows and has
/// eframe save the window layout first, as the old ⌘Q (winit's default menu) did.
///
/// Scheduled on the main queue: winit has to be out of its event handler, or eframe's exit
/// handling (and the save) is dropped.
pub fn terminate_later() {
dispatch2::DispatchQueue::main().exec_async(|| match MainThreadMarker::new() {
Some(mtm) => NSApplication::sharedApplication(mtm).terminate(None),
None => log::warn!("quit: the main queue ran off the main thread; not terminating"),
});
}

View File

@ -2,7 +2,14 @@
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//!
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
//!
//! `--in-window-menus` (or `PHOTOCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
//! The first line must authenticate; subsequent request lines get reply lines.
@ -13,12 +20,29 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod app_dirs;
mod app_icon;
#[cfg(target_os = "macos")]
mod apple_events;
mod control_server;
mod crash_guard;
mod cursor;
mod gpu_startup;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_window;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod logging;
mod monitor_profile;
mod screen_color;
mod services;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod tablet;
mod ui_state;
use photocraft_engine::Session;
use photocraft_ui_egui::PhotocraftApp;
@ -26,49 +50,195 @@ use photocraft_ui_egui::PhotocraftApp;
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
const APP_ID: &str = "ai.storyteller.photocraft";
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
eframe::icon_data::from_png_bytes(PNG).unwrap_or_default()
/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption
/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS
/// keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
/// Interface › System Title Bar asks for the system's (#1271, #1316). Read from the saved
/// preferences before the window opens; a missing or unreadable file keeps the default.
fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
let prefs: photocraft_engine::prefs::Preferences =
prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
}
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
centered: true,
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
..Default::default()
}
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
/// when there are none, else 2 (a command-line usage error), never 0.
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
#[cfg(test)]
mod control_port_tests {
use super::{control_args_exit_code, parse_control_port};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(control_args_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(control_args_exit_code(&errors), Some(2));
}
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, a following flag eaten as the value, and an empty value all fail,
// naming the offending value in the message.
let err = parse_control_port("78787", "--control").unwrap_err();
assert!(err.contains("78787"), "{err}");
assert!(parse_control_port("--safe-gpu", "--control").is_err());
assert!(parse_control_port("", "--control").is_err());
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
}
}
let mut control_token = None;
let mut control_token_file = None;
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
let mut files = Vec::new();
let mut args = std::env::args().skip(1);
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--control-token" => control_token = args.next(),
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
"--version" => {
match a.to_str() {
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
},
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => control_token = Some(token),
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
Some("--safe-gpu") => safe_gpu = true,
Some("--in-window-menus") => in_window_menus = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
_ if a.starts_with("-psn_") => {}
_ => files.push(a),
Some(s) if s.starts_with("-psn_") => {}
Some(s) => files.push(s.to_owned()),
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
// A malformed control port must not silently drop the control server (issue #701): name the
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = control_args_exit_code(&control_arg_errors) {
for error in &control_arg_errors {
eprintln!("photocraft: {error}");
}
std::process::exit(code);
}
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
#[cfg(target_os = "linux")]
let session = if session == linux_libs::DisplaySession::Wayland
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
&& linux_libs::available(linux_libs::DisplaySession::X11)
{
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
linux_libs::DisplaySession::X11
} else {
session
};
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
#[cfg(target_os = "linux")]
if let Err(message) = linux_libs::preflight(session) {
eprint!("{message}");
std::process::exit(1);
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
Err(e) => {
eprintln!("photocraft: cannot configure control authentication: {e}");
return Ok(());
std::process::exit(2);
}
};
if let Some(path) = token_file {
@ -82,7 +252,7 @@ fn main() -> eframe::Result {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
return Ok(());
std::process::exit(2);
}
};
Some((port, token, workspace))
@ -96,44 +266,166 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
// Read the main display's ICC profile while the window opens (colour-managed canvas).
// The macOS pen tablet monitor (installed once eframe created the app, see below); it lives
// until the event loop returns.
#[cfg(target_os = "macos")]
let tablet_monitor = std::cell::OnceCell::new();
#[cfg(target_os = "macos")]
let tablet_monitor = &tablet_monitor;
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
..Default::default()
let custom_titlebar = custom_titlebar(services::prefs_file().as_deref());
let mut options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; open on X11 when that was chosen.
#[cfg(target_os = "linux")]
if session == linux_libs::DisplaySession::X11 {
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
}));
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
let os = gpu_startup::Os::current();
let (pref, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
let (previous, sentinel) = match services::config_dir() {
Some(dir) => gpu_startup::Sentinel::begin(&dir),
None => (gpu_startup::Previous::Clean, None),
};
let env_backend = std::env::var("WGPU_BACKEND").ok();
let plan = gpu_startup::plan_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
if let Some(m) = previous.crashed() {
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
}
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
let backend = match &plan.env {
Some(v) => format!("env:{v}"),
None => plan.backend.name().to_string(),
};
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
if let Err(e) = s.write(marker) {
log::warn!("GPU startup marker: {e}");
}
}
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
eframe::run_native(
"Photocraft",
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_callback = app_created.clone();
let started_sentinel = sentinel.clone();
let result = eframe::run_native(
"PhotoCraft",
options,
Box::new(move |cc| {
created_in_callback.store(true, std::sync::atomic::Ordering::Relaxed);
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
let mut services = services::native(automation);
services.preset_store = presets;
#[cfg(target_os = "linux")]
let display = tablet::DisplayKind::of(cc);
#[cfg(target_os = "linux")]
{
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
if services.is_wayland {
let var = |name| std::env::var(name).ok();
services.xwayland_command =
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
#[cfg(target_os = "macos")]
mac_window::disable_native_title_drag();
// Long commands and file opens run as background jobs with progress and Cancel (#210).
app.background_jobs = std::env::var_os("PHOTOCRAFT_INLINE_JOBS").is_none();
// Displays and their profiles (#569): wait briefly so the first frames already use
// the right profile; a slower reading is applied when it arrives, and the shell reads
// again when the displays may have changed.
if let Some(rx) = monitor {
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
photocraft_ui_egui::monitor_status::apply(&mut app, Err("the display profile reader stopped without an answer".into()));
}
}
}
// Preferences › Performance › Use Graphics Processor.
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
&& app.session.prefs().performance.use_gpu
{
app.set_wgpu(rs);
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
(reason, note) => reason.or(note),
};
info.canvas = "cpu".into();
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
{
app.set_wgpu(rs);
} else {
// The window still draws with wgpu: record its errors instead of panicking.
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
(app.perf.gpu_info.canvas == "cpu" && mode != photocraft_engine::prefs::RenderingMode::Cpu)
.then(|| app.perf.gpu_info.fallback.clone())
.flatten()
});
if let Some(reason) = fallback_reason {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
{
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
}
});
if let Some((port, token, _)) = control {
let rx = control_server::start(port, token, cc.egui_ctx.clone());
app = app.with_control(rx);
@ -141,10 +433,152 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
{
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
// Quit through AppKit, not by closing the window (Touch Bar Macs, #1575, #1458).
app.services.quit = Some(Box::new(mac_window::terminate_later));
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
if !in_window_menus {
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
// Where file drags and drops are (winit 0.30 doesn't say).
app.services.cursor_pos = cursor::service(cc);
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
// write into the stylus feed. The monitor goes in here, not before the event loop:
// AppKit's shared application only exists once winit created it (#759).
#[cfg(target_os = "macos")]
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
let _ = tablet_monitor.set(monitor);
}
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
use photocraft_ui_egui::i18n;
let line = i18n::t("{path}: the path is not valid Unicode; rename the file and open it again.");
let lines = unreadable_paths.iter().map(|p| i18n::fmt(line, &[("path", p.as_str())])).collect();
photocraft_ui_egui::notices::post(&mut app, i18n::t("Could not open"), lines, true, None);
}
// Portable marker found but its data folder isn't writable (#228): say where settings went.
if let Some(w) = &app_dirs::current().warning {
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
}
Ok(Box::new(app))
}),
)
);
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
// a failed CPU start would otherwise pin every later start to CPU.
let renderer_error = gpu_startup::keep_marker_after_run(&result);
if !(renderer_error && keep_marker)
&& let Some(mut s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
{
if result.is_err()
&& !renderer_error
&& let Some(marker) = previous.crashed()
{
// This failure supplies no new graphics-crash evidence: retain the previous
// marker, rather than recording this attempt's fallback backend.
if let Err(error) = s.write(marker.clone()) {
log::warn!("couldn't restore previous GPU startup marker: {error}");
}
} else {
s.finish();
}
}
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
// Only renderer initialization failures qualify; never restart after editing has begun.
if retry_cpu && !app_created.load(std::sync::atomic::Ordering::Relaxed) && matches!(&result, Err(eframe::Error::Wgpu(_))) {
let reason = result.as_ref().err().map(ToString::to_string).unwrap_or_default();
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if let Ok(exe) = std::env::current_exe() {
let launched = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.arg("--safe-gpu")
.env_remove("WGPU_BACKEND")
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
.spawn();
match launched {
Ok(_) => return Ok(()),
Err(error) => log::error!("could not start CPU compatibility mode: {error}"),
}
}
}
result
}
#[cfg(test)]
mod tests {
#[test]
fn window_and_panel_geometry_survive_a_restart() {
let options = super::native_options(super::CUSTOM_TITLEBAR);
assert!(options.persist_window);
assert_eq!(options.persistence_path, super::services::config_dir().map(|dir| dir.join("ui.ron")));
#[derive(Default)]
struct Storage(std::collections::BTreeMap<String, String>);
impl eframe::Storage for Storage {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.into(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let ctx = egui::Context::default();
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
let mut output = ctx.run_ui(input(), |ui| {
egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let mut storage = Storage::default();
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
let restored = egui::Context::default();
restored.memory_mut(|memory| *memory = eframe::get_value(&storage, "egui").unwrap());
let mut output = restored.run_ui(input(), |ui| {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
assert_eq!(panel.size().x, 410.0);
}
#[test]
fn the_window_opens_centred_at_its_default_size() {
let o = super::native_options(super::CUSTOM_TITLEBAR);
assert!(o.centered, "#419: centred, not cascaded from the top-left corner");
assert_eq!(o.viewport.inner_size, Some(egui::vec2(1440.0, 900.0)));
// eframe shrinks the start size to the monitor, so the centred position is on-screen.
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
// system decorations on Windows and Linux; macOS always has them.
let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default");
std::fs::write(&file, "not json").unwrap();
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap();
assert!(!super::custom_titlebar(Some(&file)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
std::fs::write(&file, r#"{"interface":{"systemTitleBar":false}}"#).unwrap();
assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR);
if super::CUSTOM_TITLEBAR {
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -1,45 +1,164 @@
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`).
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`; #569).
//!
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
//! return `None` (sRGB, or the profile chosen in Color Settings).
//! macOS: every `NSScreen` with its `CGDirectDisplayID`, name, frame and
//! `colorSpace.ICCProfileData`, documented AppKit APIs read through `osascript` (AppKit via
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
//! thread; the shell starts one at launch and again when the displays may have changed
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
/// Starts reading the main display's profile in the background.
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
use photocraft_engine::display_color::Display;
/// What the platform reader returns: the displays, or why there are none.
pub type Detection = Result<Vec<Display>, String>;
/// Starts reading the displays in the background; `None` when the platform has no reader.
pub fn detect_async() -> Option<Receiver<Detection>> {
if !cfg!(target_os = "macos") {
return None;
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
rx
Some(rx)
}
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
/// points, Cocoa's bottom-left origin), the profile as base64, the profile's name in System
/// Settings and the display's name, tab-separated. Numbers as integers (text conversion of
/// reals follows the user's locale, e.g. "0,0"). The id goes through NSNumber's `stringValue`:
/// a display id above AppleScript's integer range (2^29) is otherwise coerced to a real and
/// printed as "2.077748985E+9" (#1371).
#[cfg(target_os = "macos")]
fn detect() -> Option<Vec<u8>> {
let out = std::process::Command::new("/usr/bin/osascript")
.args([
"-e",
"use framework \"AppKit\"",
"-e",
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
const SCRIPT: &str = r#"use framework "AppKit"
set out to ""
repeat with s in (current application's NSScreen's screens() as list)
set f to s's frame()
set n to (((s's deviceDescription()'s objectForKey:"NSScreenNumber")'s stringValue()) as text)
set icc to ""
set cs to s's colorSpace()
set csName to ""
if cs is not missing value then
set csName to ((cs's localizedName()) as text)
set d to cs's ICCProfileData()
if d is not missing value then set icc to ((d's base64EncodedStringWithOptions:0) as text)
end if
set out to out & n & tab & ((item 1 of item 1 of f) as integer) & tab & ((item 2 of item 1 of f) as integer) & tab & ((item 1 of item 2 of f) as integer) & tab & ((item 2 of item 2 of f) as integer) & tab & icc & tab & csName & tab & ((s's localizedName()) as text) & linefeed
end repeat
return out"#;
#[cfg(target_os = "macos")]
fn detect() -> Detection {
let mut cmd = std::process::Command::new("/usr/bin/osascript");
cmd.args(["-e", SCRIPT]);
parse_reply(&run_helper(&mut cmd, HELPER_TIMEOUT)?)
}
/// How long a reading may take before the helper is stopped (it normally takes about 0.4 s).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
const HELPER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Run the helper and return its standard output; a helper that fails, or runs longer than
/// `timeout`, is an error (and is killed and reaped), so a hung reading can't block later ones.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Result<String, String> {
use std::io::Read;
use std::process::Stdio;
let mut child =
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run the display profile reader: {e}"))?;
// Drain both pipes while waiting: a reply larger than the pipe buffer would otherwise block
// the helper until the deadline.
let drain = |p: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut p) = p {
let _ = p.read_to_end(&mut buf);
}
buf
})
};
let out = drain(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let err = drain(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let deadline = std::time::Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("reading the display profiles took longer than {} s and was stopped", timeout.as_secs()));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("waiting for the display profile reader failed: {e}"));
}
}
};
let out = out.join().unwrap_or_default();
let err = err.join().unwrap_or_default();
if !status.success() {
let err = String::from_utf8_lossy(&err);
let first = err.lines().next().unwrap_or("").trim();
return Err(format!("reading the display profiles failed ({status}){}", if first.is_empty() { String::new() } else { format!(": {first}") }));
}
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
// An ICC profile starts with its size and carries `acsp` at offset 36.
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(not(target_os = "macos"))]
fn detect() -> Option<Vec<u8>> {
None
fn detect() -> Detection {
Err("this platform doesn't report display profiles".into())
}
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
/// (the coordinates window positions use). Lines that don't parse are skipped.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn parse_reply(out: &str) -> Detection {
let mut displays = Vec::new();
let mut primary_height = None;
for line in out.lines().filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.splitn(8, '\t').collect();
let [id, x, y, w, h, b64, profile_name, name] = f.as_slice() else { continue };
let num = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let (Some(id), Some(x), Some(y), Some(w), Some(h)) = (display_id(id), num(x), num(y), num(w), num(h)) else { continue };
// Cocoa frames are relative to the primary screen's bottom-left corner, y up.
let top = *primary_height.get_or_insert(h) - (y + h);
let name = name.trim();
let name = if name.is_empty() { format!("Display {id}") } else { name.to_string() };
let b64 = b64.trim();
// An ICC profile starts with its size and carries `acsp` at offset 36; anything else is
// treated as no profile (the engine then says the display has none).
let icc = (!b64.is_empty()).then(|| base64_decode(b64)).flatten().filter(|b| b.len() >= 132 && b.get(36..40) == Some(b"acsp"));
let profile_name = Some(profile_name.trim().to_string()).filter(|n| !n.is_empty());
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
}
if displays.is_empty() {
// Only the start of the first field: a display line carries a whole base64 ICC profile,
// which must not end up in a notice (#1371).
let first = out.lines().next().unwrap_or("").split('\t').next().unwrap_or("").trim();
let mut shown: String = first.chars().take(80).collect();
if shown.len() < first.len() {
shown.push('…');
}
return Err(if shown.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {shown}") });
}
Ok(displays)
}
/// A display id: a plain integer, or the scientific notation AppleScript printed for ids above
/// its integer range before the helper asked for `stringValue` (#1371), when it is exact.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn display_id(s: &str) -> Option<u32> {
let s = s.trim();
s.parse::<u32>().ok().or_else(|| {
let v = s.parse::<f64>().ok().filter(|v| v.is_finite() && *v >= 0.0 && v.fract() == 0.0 && *v <= f64::from(u32::MAX))?;
Some(v as u32)
})
}
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
@ -87,12 +206,93 @@ mod tests {
assert!(base64_decode("a$==").is_none());
}
/// The detected profile (when there is one) parses as an RGB profile.
#[test]
fn detected_profile_parses() {
if let Some(bytes) = detect() {
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
fn helper_replies() {
// Only the ICC signature is checked here; parsing is the engine's job.
let mut icc = vec![0u8; 132];
icc[36..40].copy_from_slice(b"acsp");
let b64 = base64_encode(&icc);
// The #569 setup: built-in display primary, the external one right of it and higher up.
let reply = format!(
"1\t0\t0\t1728\t1117\t{b64}\tColor LCD\tBuilt-in Retina Display\n4\t1728\t92\t3008\t1692\t{b64}\tApple_Display_26-04-13.icc\tROG PG32UQX\n"
);
let d = parse_reply(&reply).unwrap();
assert_eq!(d.len(), 2);
assert_eq!((d[0].id, d[0].name.as_str(), d[0].frame), (1, "Built-in Retina Display", [0.0, 0.0, 1728.0, 1117.0]));
assert_eq!((d[1].id, d[1].frame), (4, [1728.0, -667.0, 3008.0, 1692.0]));
assert_eq!(d[1].profile_name.as_deref(), Some("Apple_Display_26-04-13.icc"));
assert_eq!(d[1].icc.as_deref(), Some(&icc));
// No profile, a bad profile, no name: kept, without a profile or with a fallback name.
let d = parse_reply("2\t0\t0\t800\t600\t\t\t\n3\t800\t0\t800\t600\taGVsbG8=\tX\tY\n").unwrap();
assert_eq!((d[0].name.as_str(), d[0].icc.is_none(), d[0].profile_name.is_none()), ("Display 2", true, true));
assert!(d[1].icc.is_none());
// Garbage lines are skipped; nothing usable is an error.
assert_eq!(parse_reply("junk\n5\t0\t0\t10\t10\t\t\tZ").unwrap().len(), 1);
assert!(parse_reply("").is_err());
assert!(parse_reply("execution error: -1728").unwrap_err().contains("-1728"));
}
#[test]
fn large_display_ids_and_short_errors() {
// #1371: a CGDirectDisplayID above AppleScript's integer range came back as a real.
assert_eq!(display_id("2077748985"), Some(2_077_748_985));
assert_eq!(display_id(" 2.077748985E+9 "), Some(2_077_748_985));
assert_eq!(display_id("4.294967295E+9"), Some(u32::MAX));
for bad in ["4.3E+9", "-1", "1.5", "NaN", "inf", "", "x"] {
assert_eq!(display_id(bad), None, "{bad}");
}
let d = parse_reply("2.077748985E+9\t0\t0\t1512\t982\t\tColor LCD\tBuilt-in Retina Display\n").unwrap();
assert_eq!(d[0].id, 2_077_748_985);
// A line that can't be read never puts its profile into the error.
let profile = "A".repeat(4000);
let e = parse_reply(&format!("bad id\t0\t0\t1\t1\t{profile}\tColor LCD\tX\n")).unwrap_err();
assert!(e.contains("bad id") && !e.contains("AAAA") && e.len() < 120, "{e}");
let e = parse_reply(&"Z".repeat(500)).unwrap_err();
assert!(e.chars().count() < 120 && e.ends_with('…'), "{e}");
}
// Drives `sleep` (found on PATH: NixOS and the Nix build sandbox have no /bin/sleep) and
// /bin/sh; the helper itself only runs on macOS.
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "killed at the deadline");
// 1 MB on stdout (far beyond a pipe buffer) arrives whole.
let big = run_helper(std::process::Command::new("/bin/sh").args(["-c", "head -c 1048576 /dev/zero | tr '\\0' a"]), std::time::Duration::from_secs(20))
.unwrap();
assert_eq!(big.len(), 1 << 20);
let e = run_helper(std::process::Command::new("/bin/sh").args(["-c", "echo boom >&2; exit 3"]), std::time::Duration::from_secs(5)).unwrap_err();
assert!(e.contains("boom"), "{e}");
assert!(run_helper(&mut std::process::Command::new("/nonexistent/helper"), std::time::Duration::from_secs(1)).is_err());
}
fn base64_encode(b: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in b.chunks(3) {
let n = c.iter().enumerate().fold(0u32, |n, (i, v)| n | u32::from(*v) << (16 - 8 * i));
for i in 0..=c.len() {
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
}
}
while !s.len().is_multiple_of(4) {
s.push('=');
}
s
}
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
#[test]
fn detected_displays_parse() {
if let Ok(displays) = detect() {
for d in displays {
let Some(icc) = d.icc else { continue };
let p = photocraft_engine::color_cmds::profile_from_bytes(&icc).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}: {}", d.name, p.description);
}
}
}
}

View File

@ -0,0 +1,413 @@
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
return None;
}
let window = cc.winit_window()?.clone();
Some(Box::new(move |ctx| {
let (tx, rx) = std::sync::mpsc::channel();
let fallback = tx.clone();
let placements: Vec<Placement> = window
.available_monitors()
.enumerate()
.map(|(index, m)| {
let p = m.position();
let size = m.size();
Placement { index, x: p.x, y: p.y, width: size.width, height: size.height }
})
.collect();
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop = cancelled.clone();
let scale = ctx.native_pixels_per_point().unwrap_or(1.0);
let wake = ctx.clone();
if let Err(e) = std::thread::Builder::new().name("screen-color-picker".into()).spawn(move || {
let result = capture(wayland, scale, &stop, &placements);
let _ = tx.send(result);
wake.request_repaint();
}) {
let _ = fallback.send(Err(format!("Could not start screen color picker: {e}")));
}
Pending { receiver: rx, cancelled }
}))
}
#[derive(Clone, Copy)]
struct Placement {
index: usize,
x: i32,
y: i32,
width: u32,
height: u32,
}
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
}
fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
#[cfg(target_os = "linux")]
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
if wayland {
return portal(stop);
}
use x11rb::{
connection::Connection,
image::{Image, PixelLayout},
protocol::{
randr::ConnectionExt as _,
xproto::{AtomEnum, ConnectionExt, VisualClass},
},
};
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
count(usize::from(w), usize::from(h))?;
let mut monitors = conn.randr_get_monitors(screen.root, true).ok().and_then(|c| c.reply().ok()).map(|r| r.monitors).unwrap_or_default();
monitors.sort_by_key(|m| !m.primary);
if !monitors.iter().any(|m| m.primary)
&& let Some(first) = monitors.first_mut()
{
first.primary = true;
}
let regions: Vec<(i16, i16, u16, u16, bool)> =
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
if regions.len() > 16 {
return Err("Too many screens for the color picker".into());
}
let mut total = 0usize;
for &(x, y, mw, mh, _) in &regions {
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
return Err("Invalid X11 display geometry".into());
}
total = total
.checked_add(count(usize::from(mw), usize::from(mh))?)
.filter(|&n| n <= MAX_PIXELS)
.ok_or("Combined displays exceed the screen picker memory budget")?;
}
let (image, visual_id) = Image::get(&conn, screen.root, 0, 0, w, h).map_err(|e| format!("Screen capture failed: {e}"))?;
let visual = screen.allowed_depths.iter().flat_map(|d| &d.visuals).find(|v| v.visual_id == visual_id).ok_or("Unsupported X11 screen visual")?;
if visual.class != VisualClass::TRUE_COLOR {
return Err("Screen picking requires an X11 TrueColor visual".into());
}
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
let primary_profile = if atom == 0 {
None
} else {
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
};
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
let mut images = Vec::new();
for (x, y, mw, mh, primary) in regions {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
for row in 0..mh {
for col in 0..mw {
let (r, g, b) = layout.decode(image.get_pixel((x as u16) + col, (y as u16) + row));
rgba.extend_from_slice(&[byte(r), byte(g), byte(b), 255]);
}
}
images.push(ScreenImage {
monitor: find_monitor(placements, i32::from(x), i32::from(y), u32::from(mw), u32::from(mh)),
position: egui::pos2(f32::from(x), f32::from(y)) / scale,
size: egui::vec2(f32::from(mw), f32::from(mh)) / scale,
width: usize::from(mw),
height: usize::from(mh),
rgba,
profile: if primary { primary_profile.clone() } else { None },
});
}
Ok(Capture::Images(images))
}
#[cfg(target_os = "linux")]
fn portal(stop: &std::sync::atomic::AtomicBool) -> Result<Capture, String> {
use std::collections::HashMap;
use zbus::{
blocking::Proxy,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
let conn = zbus::blocking::connection::Builder::session()
.map_err(|e| e.to_string())?
.method_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
let token = format!("photocraft_{}", std::process::id());
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
let request = Proxy::new(&conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
use futures_lite::StreamExt;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
let screenshot = Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
.map_err(|e| e.to_string())?;
let options: HashMap<&str, Value<'_>> = HashMap::from([("handle_token", Value::from(token.as_str()))]);
let _handle: OwnedObjectPath =
screenshot.call("PickColor", &("", options)).map_err(|e| format!("The desktop portal does not support color picking: {e}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let reply = loop {
if stop.load(std::sync::atomic::Ordering::Relaxed) || std::time::Instant::now() >= deadline {
let _: Result<(), _> = request.call("Close", &());
return Ok(Capture::Color(None));
}
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
async_io::Timer::after(std::time::Duration::from_millis(50)).await;
None
}));
if let Some(message) = next {
break message.ok_or("The desktop color picker closed without a result")?;
}
};
let (code, values): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
if code == 1 {
return Ok(Capture::Color(None));
}
if code != 0 {
return Err("The desktop color picker could not sample this screen".into());
}
let value = values.get("color").ok_or("Desktop picker returned no color")?;
let (r, g, b): (f64, f64, f64) = value.try_clone().map_err(|e| e.to_string())?.try_into().map_err(|e: zbus::zvariant::Error| e.to_string())?;
if [r, g, b].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
return Err("Desktop picker returned an invalid color".into());
}
Ok(Capture::Color(Some([r as f32, g as f32, b as f32])))
}
#[cfg(target_os = "windows")]
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
let monitors = xcap::Monitor::all().map_err(|e| e.to_string())?;
if monitors.is_empty() || monitors.len() > 16 {
return Err("No supported screen is available".into());
}
let mut budget = 0usize;
for m in &monitors {
budget = budget
.checked_add(count(m.width().map_err(|e| e.to_string())? as usize, m.height().map_err(|e| e.to_string())? as usize)?)
.filter(|&n| n <= MAX_PIXELS)
.ok_or("Combined displays exceed the screen picker memory budget")?;
}
let mut total = 0usize;
let mut images = Vec::new();
for m in monitors {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let (w, h) = (m.width().map_err(|e| e.to_string())?, m.height().map_err(|e| e.to_string())?);
let scale = m.scale_factor().map_err(|e| e.to_string())?;
if !scale.is_finite() || scale <= 0.0 || scale > 16.0 {
return Err("Invalid display scale".into());
}
let captured = m.capture_image().map_err(|e| format!("Screen capture failed: {e}"))?;
let (width, height) = (captured.width() as usize, captured.height() as usize);
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
let (x, y) = (m.x().map_err(|e| e.to_string())?, m.y().map_err(|e| e.to_string())?);
images.push(ScreenImage {
monitor: find_monitor(placements, x, y, w, h),
position: egui::pos2(x as f32, y as f32) / scale,
size: egui::vec2(w as f32, h as f32) / scale,
width,
height,
rgba: captured.into_raw(),
profile: None,
});
}
Ok(Capture::Images(images))
}
#[cfg(target_os = "macos")]
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
use core_graphics2::{display::get_active_display_list, image::CGImageAlphaInfo as Alpha, window};
if !window::preflight_screen_capture_access() && !window::request_screen_capture_access() {
return Err("Allow PhotoCraft in System Settings > Privacy & Security > Screen Recording, then reopen PhotoCraft".into());
}
let displays = get_active_display_list(17).ok_or("Could not enumerate displays")?;
if displays.is_empty() || displays.len() > 16 {
return Err("No supported screen is available".into());
}
let mut budget = 0usize;
for display in &displays {
let mode = display.copy_display_mode().ok_or("Display mode is unavailable")?;
budget = budget
.checked_add(count(mode.pixel_width(), mode.pixel_height())?)
.filter(|&n| n <= MAX_PIXELS)
.ok_or("Combined displays exceed the screen picker memory budget")?;
}
let mut images = Vec::new();
let mut total = 0usize;
for display in displays {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
// Capture a composed monitor at native resolution through safe bindings. Keeping the
// CoreGraphics seam preserves PhotoCraft's existing macOS 11 deployment target.
let bounds = display.bounds();
let image = window::new_image(
bounds,
window::CGWindowListOption::OnScreenOnly,
window::kCGNullWindowID,
window::CGWindowImageOption::BestResolution | window::CGWindowImageOption::ShouldBeOpaque,
)
.ok_or("Screen capture failed; check Screen Recording permission")?;
let (width, height) = (image.width(), image.height());
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
let profile = image.color_space().and_then(|space| space.copy_icc_data());
if profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024) {
return Err("Screen profile exceeds the memory budget".into());
}
let info = image.bitmap_info().bits();
if image.bits_per_component() != 8 || image.bits_per_pixel() != 32 || info & (1 << 8) != 0 {
return Err("Unsupported screen capture pixel format".into());
}
let (first, premultiplied, opaque) = match image.alpha_info() {
Alpha::AlphaPremultipliedFirst => (true, true, false),
Alpha::AlphaPremultipliedLast => (false, true, false),
Alpha::AlphaFirst => (true, false, false),
Alpha::AlphaLast => (false, false, false),
Alpha::AlphaNoneSkipFirst => (true, false, true),
Alpha::AlphaNoneSkipLast => (false, false, true),
_ => return Err("Unsupported screen capture alpha layout".into()),
};
let little = match info & 0x7000 {
0 => cfg!(target_endian = "little"),
0x2000 => true,
0x4000 => false,
_ => return Err("Unsupported screen capture byte order".into()),
};
let order = match (first, little) {
(true, true) => [2, 1, 0, 3],
(true, false) => [1, 2, 3, 0],
(false, true) => [3, 2, 1, 0],
(false, false) => [0, 1, 2, 3],
};
let stride = image.bytes_per_row();
checked_stride(width, height, stride)?;
let bytes = image.data_provider().and_then(|p| p.copy_data()).ok_or("Screen image has no pixels")?;
let rgba = unpack_rgba(bytes.bytes(), width, height, stride, order, premultiplied, opaque)?;
let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
let (x, y) = (bounds.origin.x as f32, bounds.origin.y as f32);
if ![w, h, x, y].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 {
return Err("Invalid display geometry".into());
}
let scale = width as f32 / w;
images.push(ScreenImage {
monitor: find_monitor(placements, (x * scale).round() as i32, (y * scale).round() as i32, width as u32, height as u32),
position: egui::pos2(x, y),
size: egui::vec2(w, h),
width,
height,
rgba,
profile: profile.map(|p| p.bytes().to_vec()),
});
}
Ok(Capture::Images(images))
}
// CoreGraphics rows can be padded and their byte order/alpha vary. Kept platform independent
// so Linux CI also tests the macOS decoding path against synthetic pixels.
#[cfg(any(target_os = "macos", test))]
fn checked_stride(width: usize, height: usize, stride: usize) -> Result<usize, String> {
count(width, height)?;
let row = width.checked_mul(4).ok_or("Invalid screen row")?;
if stride < row || stride > row.saturating_add(4096) {
return Err("Invalid screen row stride".into());
}
stride.checked_mul(height).filter(|&n| n <= MAX_PIXELS.saturating_mul(4)).ok_or_else(|| "Screen buffer exceeds the memory budget".into())
}
#[cfg(any(target_os = "macos", test))]
fn unpack_rgba(bytes: &[u8], width: usize, height: usize, stride: usize, order: [usize; 4], premultiplied: bool, opaque: bool) -> Result<Vec<u8>, String> {
let length = checked_stride(width, height, stride)?;
if bytes.len() < length || order.iter().any(|&i| i > 3) {
return Err("Invalid screen pixel buffer".into());
}
let mut out = Vec::with_capacity(count(width, height)?.saturating_mul(4));
for row in bytes.chunks_exact(stride).take(height) {
for p in row[..width * 4].as_chunks::<4>().0 {
let a = if opaque { 255 } else { p[order[3]] };
for &i in &order[..3] {
let v = u32::from(p[i]);
out.push(if premultiplied && a != 255 {
if a == 0 { 0 } else { ((v * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }
} else {
v as u8
});
}
out.push(255);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screen_capture_decodes_padded_bgra_and_premultiplied_argb() {
assert_eq!(
unpack_rgba(&[10, 20, 30, 0, 99, 99, 99, 99, 40, 50, 60, 0, 99, 99, 99, 99], 1, 2, 8, [2, 1, 0, 3], false, true).unwrap(),
[30, 20, 10, 255, 60, 50, 40, 255]
);
assert_eq!(unpack_rgba(&[128, 64, 32, 16], 1, 1, 4, [1, 2, 3, 0], true, false).unwrap(), [128, 64, 32, 255]);
assert_eq!(unpack_rgba(&[0, 0, 0, 0], 1, 1, 4, [0, 1, 2, 3], true, false).unwrap(), [0, 0, 0, 255]);
}
#[test]
fn screen_capture_rejects_unbounded_or_truncated_buffers() {
assert!(count(usize::MAX, 2).is_err());
assert!(checked_stride(1, 1, usize::MAX).is_err());
assert!(checked_stride(1, MAX_PIXELS, 4096).is_err());
assert!(unpack_rgba(&[0; 3], 1, 1, 4, [0, 1, 2, 3], false, true).is_err());
assert!(unpack_rgba(&[0; 4], 1, 1, 4, [4, 1, 2, 3], false, true).is_err());
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
fn screen_capture_reads_a_known_x11_fixture_pixel() {
use x11rb::{
COPY_DEPTH_FROM_PARENT,
connection::Connection,
protocol::xproto::{ConfigureWindowAux, ConnectionExt, CreateWindowAux, StackMode, WindowClass},
};
let (conn, index) = x11rb::connect(None).unwrap();
let screen = &conn.setup().roots[index];
let window = conn.generate_id().unwrap();
conn.create_window(
COPY_DEPTH_FROM_PARENT,
window,
screen.root,
200,
200,
8,
8,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new().override_redirect(1).background_pixel(0xff8040),
)
.unwrap()
.check()
.unwrap();
conn.map_window(window).unwrap().check().unwrap();
conn.configure_window(window, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE)).unwrap().check().unwrap();
conn.flush().unwrap();
// X11 request completion precedes the compositor presenting the mapped fixture.
std::thread::sleep(std::time::Duration::from_millis(150));
let images = capture(false, 1.0, &std::sync::atomic::AtomicBool::new(false), &[]);
conn.destroy_window(window).unwrap().check().unwrap();
conn.flush().unwrap();
let Capture::Images(images) = images.unwrap() else { panic!("not an image capture") };
let first = images.iter().find(|m| m.position.x <= 200.0 && m.position.y <= 200.0).unwrap();
let x = (200.0 - first.position.x) as usize;
let y = (200.0 - first.position.y) as usize;
let offset = (y * first.width + x) * 4;
assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
}
#[test]
fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
assert_eq!(find_monitor(&m, -3840, 0, 3840, 2160), Some(2));
assert_eq!(find_monitor(&m, 0, 0, 1920, 1080), None);
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn capture(_wayland: bool, _scale: f32, _stop: &std::sync::atomic::AtomicBool, _placements: &[Placement]) -> Result<Capture, String> {
Err("Screen color picking is unavailable on this platform".into())
}

View File

@ -3,57 +3,149 @@
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::Autosaver;
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::Services;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recovered, Services};
use std::cell::RefCell;
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
"afdesign", "afphoto", "afpub",
];
/// Open dialog extensions that also match uppercase and mixed-case names (`IMG_0001.JPG`).
/// On Linux and the BSDs rfd turns each extension into a case-sensitive `*.ext` glob for the XDG
/// portal or zenity, so every letter becomes a character class, as the portal spec suggests
/// (`*.[iI][cC][oO]`). Windows and macOS take literal extensions and ignore case already.
fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
let case_sensitive_globs = cfg!(all(unix, not(target_os = "macos")));
let class = |c: char| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() };
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
/// keeps defaulting to Photoshop.
const SAVE_FILTERS: &[(&str, &[&str])] =
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PSD Document", &["psd", "psb"]),
("PhotoCraft", &["pcraft"]),
("PNG", &["png"]),
("JPEG", &["jpg"]),
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
];
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let mut v = SAVE_FILTERS.to_vec();
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
let f = v.remove(i);
v.insert(0, f);
if let Some((name, exts)) = OTHER_SAVE_FILTERS.iter().find(|(_, exts)| exts.contains(&ext.as_str())) {
return vec![(name.to_string(), exts.iter().map(|e| e.to_string()).collect())];
}
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
Some(i) => {
let f = v.remove(i);
v.insert(0, f);
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
}
v
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
/// `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`).
pub fn config_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("PHOTOCRAFT_CONFIG_DIR") {
return Some(PathBuf::from(d));
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, reply: FileDialogReply) {
let mut dialog = rfd::AsyncFileDialog::new();
if let Some(parent) = parent {
dialog = dialog.set_parent(parent);
}
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home.map(|h| h.join("Library/Application Support/Photocraft"));
let answer: Pin<Box<dyn Future<Output = Option<FileDialogAnswer>> + Send>> = match request {
FileDialogRequest::Open { multiple, initial_dir, extensions } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
// A command-specific filter must not inherit the image formats used by File › Open.
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
} else {
dialog.add_filter("All Formats", &open_filter_extensions(OPEN_EXTS)).add_filter("PhotoCraft", &open_filter_extensions(&["pcraft"]))
};
if multiple {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
} else {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = Path::new(&suggested).file_name() {
dialog = dialog.set_file_name(name.to_string_lossy());
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
dialog = dialog.set_directory(dir);
}
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
}
};
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || reply.send(block_on(answer))) {
log::error!("couldn't wait for the file dialog: {e}");
}
if cfg!(windows) {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Photocraft"));
}
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
}
fn prefs_file() -> Option<PathBuf> {
fn path_of(file: &rfd::FileHandle) -> String {
file.path().to_string_lossy().into_owned()
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));
let mut cx = std::task::Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut cx) {
return value;
}
// Spurious wake-ups just poll again.
std::thread::park();
}
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it; see
/// [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
pub fn prefs_file() -> Option<PathBuf> {
config_dir().map(|d| d.join("preferences.json"))
}
@ -67,19 +159,91 @@ fn recovery_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Recovery"))
}
/// Write `bytes` atomically (temp file + rename) so a crash never leaves half a preferences file.
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
/// [`photocraft_format::atomic`]). Every document write (Save, Save As, Export, Save for Web) and
/// the preferences go through here, so a failed or interrupted save never destroys the old file.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
/// Run `f` on the recovery store (`Err` without a config directory). The service closures never
/// call each other, so the store is never borrowed twice.
fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R) -> Result<R, String> {
let mut slot = store.try_borrow_mut().map_err(|_| "crash recovery is busy".to_string())?;
Ok(f(slot.as_mut().ok_or("no config directory")?))
}
/// Crash recovery: background incremental .pcraft autosaves into `dir` (`None`: no config
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
fn recovery_services(dir: Option<PathBuf>) -> Services {
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s3, s4) = (store.clone(), store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
recover: Some(Box::new(move || {
let found = with_store(&s3, |s| s.recover()).unwrap_or_default();
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
})),
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
let _ = with_store(&store, |s| s.adopt(id, key));
})),
..Default::default()
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
paths.iter().find_map(|path| {
// text/uri-list lines end in CRLF (RFC 2483, and GTK writes them so), but arboard splits
// on LF only, so a path copied in GNOME Files arrives with a trailing '\r'.
let path = path.to_str().map_or_else(|| path.clone(), |s| PathBuf::from(s.trim_end_matches('\r')));
let mut head = Vec::with_capacity(256);
std::fs::File::open(&path).ok()?.take(256).read_to_end(&mut head).ok()?;
// TGA has no magic number and is only guessed from the header, so trust the extension
// too before reading what may be a large non-image file.
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match photocraft_codecs::detect(&head)? {
photocraft_codecs::Format::Tga if photocraft_codecs::from_extension(ext) != Some(photocraft_codecs::Format::Tga) => return None,
_ => {}
}
let img = photocraft_codecs::decode(&std::fs::read(&path).ok()?).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
/// The shell command that starts this install under XWayland, where winit delivers file drops
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
/// `None` outside Flatpak when there is no X server (`DISPLAY` unset) to run on.
#[cfg(any(target_os = "linux", test))]
pub fn xwayland_command(flatpak_id: Option<&str>, appimage: Option<&str>, x_display: bool) -> Option<String> {
if let Some(id) = flatpak_id.filter(|id| !id.is_empty()) {
return Some(format!("flatpak run --nosocket=wayland --socket=x11 {}", shell_word(id)));
}
let exe = appimage.filter(|path| !path.is_empty()).map_or_else(|| "photocraft".to_string(), shell_word);
x_display.then(|| format!("WAYLAND_DISPLAY= {exe}"))
}
/// `s` as one POSIX shell word: bare when every character is safe there, else single-quoted.
#[cfg(any(target_os = "linux", test))]
fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@ -90,6 +254,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let automation_write = automation.clone().map(|workspace| {
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
let automation_authorize = automation.is_some().then_some(step);
let automation_command = automation.map(|_| {
Box::new(|id: &str, params: &serde_json::Value| {
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
@ -104,69 +270,56 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
})),
pick_open: Some(Box::new(|| {
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
let bytes = std::fs::read(&path).ok()?;
Some((path.to_string_lossy().to_string(), bytes))
})),
pick_save: Some(Box::new(|suggested: &str| {
let p = std::path::Path::new(suggested);
let mut d = rfd::FileDialog::new();
for (name, exts) in save_filters(suggested) {
d = d.add_filter(name, exts);
}
if let Some(name) = p.file_name() {
d = d.set_file_name(name.to_string_lossy());
}
Some(d.save_file()?.to_string_lossy().to_string())
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
file_dialog: Some(Box::new(show_file_dialog)),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
automation_read,
automation_write,
automation_command,
automation_authorize,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})),
clipboard_get_image: Some(Box::new(|| {
let img = arboard::Clipboard::new().ok()?.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?),
};
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})
}),
clipboard_get_image: Some({
let clip = clip.clone();
Box::new(move || {
let mut slot = clip.try_borrow_mut().ok()?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
// Crash recovery: background incremental .pcraft saves into the recovery directory.
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
let dir = recovery_dir().ok_or("no config directory")?;
let mut map = savers.borrow_mut();
let saver = map.entry(doc.id.0).or_insert_with(|| Autosaver::new(&dir, &format!("doc-{}", doc.id.0)));
saver.request(doc.clone(), revision, path.map(str::to_string), Default::default());
Ok(())
})),
discard_autosave: Some(Box::new(move |id: u64| {
if let Some(s) = savers2.borrow_mut().remove(&id) {
let _ = s.discard();
}
})),
recover: Some(Box::new(|| {
let Some(dir) = recovery_dir() else { return Vec::new() };
let mut out = Vec::new();
for entry in photocraft_format::list_recovery(&dir) {
if let Ok(doc) = photocraft_format::recover(&entry) {
out.push((entry.info.original_path.clone(), doc));
}
// Recovered documents autosave again under their new ids.
let _ = photocraft_format::discard_recovery(&dir, &entry);
}
out
})),
append_text: Some(Box::new(|path: &str, text: &str| {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
@ -176,6 +329,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
os_events: None,
// Set by main, which starts loading the store before the window opens.
preset_store: None,
is_wayland: false,
..recovery_services(recovery_dir())
}
}
@ -240,3 +395,364 @@ pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_engine::Session;
use photocraft_format::list_recovery;
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
// Matches `text` against a glob of literals and `[..]` classes, as the portal would after `*.`.
fn matches(glob: &str, text: &str) -> bool {
let mut text = text.chars();
let mut glob = glob.chars();
while let Some(g) = glob.next() {
let class: String = if g == '[' { glob.by_ref().take_while(|&c| c != ']').collect() } else { g.to_string() };
if !text.next().is_some_and(|t| class.contains(t)) {
return false;
}
}
text.next().is_none()
}
assert_eq!(open_filter_extensions(&["cr2", "pcraft"]), ["[cC][rR]2", "[pP][cC][rR][aA][fF][tT]"]);
let globs = open_filter_extensions(OPEN_EXTS);
assert_eq!(globs.len(), OPEN_EXTS.len());
for (ext, glob) in OPEN_EXTS.iter().zip(&globs) {
// Every casing: lower, UPPER, and alternating both ways (JpG, jPg).
let alternating = |upper_first: bool| -> String {
ext.chars().enumerate().map(|(i, c)| if (i % 2 == 0) == upper_first { c.to_ascii_uppercase() } else { c }).collect()
};
for name in [ext.to_string(), ext.to_ascii_uppercase(), alternating(true), alternating(false)] {
assert!(matches(glob, &name), "{glob} should match .{name}");
}
assert!(!matches(glob, &format!("{ext}x")) && !matches(glob, &ext[..ext.len() - 1]), "{glob} matches only .{ext}");
}
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
#[test]
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
}
#[test]
fn xwayland_command_matches_how_photocraft_was_installed() {
// Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do.
let flatpak = xwayland_command(Some("ai.storyteller.photocraft"), None, false);
assert_eq!(flatpak.as_deref(), Some("flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft"));
// AppImage: its own path, quoted for the shell (spaces, quotes).
let appimage = xwayland_command(None, Some("/home/me/My Apps/it's.AppImage"), true);
assert_eq!(appimage.as_deref(), Some(r"WAYLAND_DISPLAY= '/home/me/My Apps/it'\''s.AppImage'"));
let bare = xwayland_command(None, Some("/opt/photocraft-0.3.0-linux-x86_64.AppImage"), true);
assert_eq!(bare.as_deref(), Some("WAYLAND_DISPLAY= /opt/photocraft-0.3.0-linux-x86_64.AppImage"));
// deb, rpm, AUR, tarball: the binary on the PATH.
assert_eq!(xwayland_command(None, None, true).as_deref(), Some("WAYLAND_DISPLAY= photocraft"));
assert_eq!(xwayland_command(Some(""), Some(""), true).as_deref(), Some("WAYLAND_DISPLAY= photocraft"));
// No X server (XWayland disabled): nothing to suggest.
assert_eq!(xwayland_command(None, Some("/opt/p.AppImage"), false), None);
}
/// Tests every native save dialog and records the suggested file name. Each name's file type must come first
/// in the save panel, or the panel appends the first type's extension (`photo.webp.psd`, `photo.gif.psd`).
#[test]
fn every_save_dialog_leads_with_its_own_extension() {
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
let log = asked.clone();
// Record each save dialog's suggested name and cancel it, as the user would.
let services = Services {
file_dialog: Some(Box::new(move |request, _parent, reply| {
if let FileDialogRequest::Save { suggested } = request {
log.borrow_mut().push(suggested);
}
reply.send(None);
})),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
// Dialogs are shown on the next frame: poll once so each is answered before the next asks.
let invoke = |app: &mut PhotocraftApp, id: &str| {
let r = photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
app.poll_file_dialog(&ctx, None);
r
};
let dialog = |app: &mut PhotocraftApp, id: &str, fields: Value| {
let d = invoke(app, id).unwrap()["dialog"].as_u64().unwrap();
for (k, v) in fields.as_object().unwrap() {
app.ui.dialog_mut(d).unwrap().fields.insert(k.clone(), v.clone());
}
let _ = photocraft_ui_egui::dialogs::confirm(app, d);
app.poll_file_dialog(&ctx, None);
};
new_doc(&mut app, "#ff0000");
// Save As, Save a Copy.
// Files in a format Save As can't write, like .dng, should suggest .psd instead.
let _ = invoke(&mut app, "file.saveAs");
for name in ["cat.pcraft", "cat.jpeg", "cat.gif", "cat.bmp", "cat.dng"] {
app.session.active_mut().unwrap().path = Some(name.into());
let _ = invoke(&mut app, "file.saveAs");
}
let _ = invoke(&mut app, "file.saveACopy");
// Export As, Quick Export, Save for Web (with and without slices).
for format in ["png", "jpg", "webp", "tif", "tga"] {
dialog(&mut app, "file.export.exportAs", json!({"format": format}));
}
for format in ["png", "jpg", "gif", "webp"] {
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
let _ = invoke(&mut app, "file.export.quickExportAsPng");
}
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
}
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
dialog(&mut app, "file.export.saveForWebLegacy", json!({"html": true}));
// Measurement Log, Export/Import Presets.
app.run("image.analysis.recordMeasurements", json!({})).unwrap();
let _ = invoke(&mut app, "measurementLog.export");
dialog(&mut app, "edit.presets.exportImportPresets", json!({"action": "export"}));
let asked = asked.borrow();
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
// The extension each save above should suggest, in order.
let want = [
"psd", // Save As untitled
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
"psd", // Save a Copy
"png jpg webp tif tga", // Export As
"png jpg gif webp", // Quick Export
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
"csv pcpresets", // Measurement Log, Export Presets
]
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
for (name, ext) in asked.iter().zip(exts) {
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
let mut woken = false;
let ready = std::future::poll_fn(move |cx| {
if woken {
return std::task::Poll::Ready(7);
}
woken = true;
let _ = tx.send(cx.waker().clone());
std::task::Poll::Pending
});
let waker = std::thread::spawn(move || rx.recv().unwrap().wake());
assert_eq!(block_on(ready), 7);
waker.join().unwrap();
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
/// already queued finish, nothing else runs).
fn launch(dir: &Path) -> PhotocraftApp {
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
}
/// The first pixel of each open document, and whether it's unsaved.
fn open_docs(app: &PhotocraftApp) -> Vec<([f32; 4], bool)> {
app.session.documents().iter().map(|d| (photocraft_compose::flatten(&d.doc).px.first().copied().unwrap_or_default(), d.is_dirty())).collect()
}
fn index_of(app: &PhotocraftApp, color: [f32; 4]) -> usize {
open_docs(app).iter().position(|(c, _)| *c == color).unwrap()
}
fn new_doc(app: &mut PhotocraftApp, color: &str) {
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
app.run("edit.fill", json!({"color": color})).unwrap();
}
fn autosave(app: &mut PhotocraftApp, ctx: &egui::Context) {
prefs_ui::autosave_now(app);
prefs_ui::tick(app, ctx);
}
#[test]
fn recovered_documents_survive_a_second_crash_until_saved_or_closed() {
let dir = std::env::temp_dir().join(format!("photocraft-recovery-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let ctx = egui::Context::default();
// Launch 1: two unsaved documents are autosaved, then the app crashes.
{
let mut app = launch(&dir);
new_doc(&mut app, "#ff0000");
new_doc(&mut app, "#0000ff");
autosave(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 2);
// Launch 2 recovers both and crashes again before its first autosave (#444).
{
let mut app = launch(&dir);
let mut docs = open_docs(&app);
docs.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]));
assert_eq!(docs, [(BLUE, true), (RED, true)], "recovered and still unsaved");
prefs_ui::tick(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 2, "recovering keeps the entries on disk");
// Launch 3 has both again; autosaving unchanged documents adds nothing, a new document
// (whose id may repeat one from an earlier launch) gets an entry of its own.
{
let mut app = launch(&dir);
assert_eq!(app.session.documents().len(), 2);
autosave(&mut app, &ctx);
new_doc(&mut app, "#00ff00");
autosave(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 3);
// Launch 4: closing and saving recovered documents removes their entries, no duplicates.
let mut app = launch(&dir);
assert_eq!(app.session.documents().len(), 3);
let red = index_of(&app, RED);
app.run("file.close", json!({"document": red})).unwrap();
// Saving marks the revision saved (the file write itself is the save service's job).
assert!(app.session.set_active(index_of(&app, BLUE)));
let st = app.session.active_mut().unwrap();
st.saved_revision = st.revision;
prefs_ui::tick(&mut app, &ctx);
let left = list_recovery(&dir);
assert_eq!(left.len(), 1);
assert_eq!(photocraft_compose::flatten(&photocraft_format::recover(&left[0]).unwrap()).px.first().copied(), Some(GREEN));
let green = index_of(&app, GREEN);
app.run("file.close", json!({"document": green})).unwrap();
prefs_ui::tick(&mut app, &ctx);
assert!(list_recovery(&dir).is_empty());
drop(app);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_background_autosave_is_reported_and_retried_without_another_edit() {
let dir = temp("autosave-retry");
let recovery = dir.join("Recovery");
// A regular file prevents creation of the recovery bundle directory.
std::fs::write(&recovery, b"blocked").unwrap();
let ctx = egui::Context::default();
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
autosave(&mut app, &ctx);
let mut failed = false;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
if app.ui.status.starts_with("Autosave failed:") {
failed = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(failed, "background write failures must reach the UI");
assert!(app.ui.status_error);
assert!(list_recovery(&recovery).is_empty());
// No edit occurs: retrying this very same revision must still work.
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if !recovered.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(recovered.len(), 1, "unchanged revision should retry after failure");
assert_eq!(recovered[0].info.revision, revision);
let restored = photocraft_format::recover(&recovered[0]).unwrap();
assert_eq!(photocraft_compose::flatten(&restored).px.first().copied(), Some(RED));
drop(app);
std::fs::remove_dir_all(dir).unwrap();
}
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-services-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn red_png(w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).unwrap()
}
/// A 1×1 uncompressed 32-bit TGA (TGA has no magic number).
fn tga_1x1() -> Vec<u8> {
let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8];
b.extend_from_slice(&[0, 0, 255, 255]); // BGRA red
b
}
#[test]
fn copied_image_file_pastes_its_pixels() {
let dir = temp("png");
let png = dir.join("red.png");
std::fs::write(&png, red_png(3, 2)).unwrap();
let (w, h, px) = image_from_files(&[png]).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(px, [255u8, 0, 0, 255].repeat(6));
}
#[test]
fn crlf_from_a_uri_list_is_ignored() {
// arboard splits text/uri-list on LF, so GNOME Files' CRLF list leaves a '\r' behind.
let dir = temp("crlf");
let png = dir.join("red image.png");
std::fs::write(&png, red_png(2, 2)).unwrap();
let with_cr = PathBuf::from(format!("{}\r", png.display()));
assert_eq!(image_from_files(&[with_cr]).map(|(w, h, _)| (w, h)), Some((2, 2)));
}
#[test]
fn non_images_are_skipped_for_the_first_image() {
let dir = temp("mixed");
let (txt, png) = (dir.join("notes.txt"), dir.join("red.png"));
std::fs::write(&txt, b"not an image").unwrap();
std::fs::write(&png, red_png(4, 1)).unwrap();
let missing = dir.join("gone.png");
assert_eq!(image_from_files(&[missing.clone(), txt.clone(), dir.clone(), png]).map(|(w, h, _)| (w, h)), Some((4, 1)));
assert!(image_from_files(&[missing, txt, dir]).is_none());
assert!(image_from_files(&[]).is_none());
}
#[test]
fn a_damaged_image_is_skipped_not_a_panic() {
let dir = temp("damaged");
let bad = dir.join("cut.png");
std::fs::write(&bad, &red_png(8, 8)[..20]).unwrap();
assert!(image_from_files(&[bad]).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
let (tga, bin) = (dir.join("red.tga"), dir.join("red.bin"));
std::fs::write(&tga, tga_1x1()).unwrap();
std::fs::write(&bin, tga_1x1()).unwrap();
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
}

View File

@ -0,0 +1,301 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//!
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force.
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
//! `unsafe`, see the crate docs): there the pen does nothing in the window, not even move the
//! pointer (#639). Xwayland binds it and serves the pen as an XInput2 device, so with a pen
//! attached the app opens its window through Xwayland ([`display_session`]), where the X11 reader
//! gets pressure, tilt and the eraser end.
#[cfg(any(target_os = "linux", test))]
use crate::linux_libs::DisplaySession;
use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
#[cfg(target_os = "macos")]
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
}
/// The display server to open the window on: the session's own, except Xwayland on Wayland when
/// a pen is attached (see the module docs) and Xwayland can run the window: `$DISPLAY` is set
/// and the X11 libraries are installed. `PHOTOCRAFT_NATIVE_WAYLAND=1` keeps native Wayland.
#[cfg(target_os = "linux")]
pub fn display_session() -> DisplaySession {
let var = |k: &str| std::env::var(k).ok();
let session = crate::linux_libs::session_from_env(var);
let pen = || std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|d| has_pen(&d));
let x11_libs = || crate::linux_libs::available(DisplaySession::X11);
if pen_needs_xwayland(session, var, pen, x11_libs) {
eprintln!("photocraft: pen found; opening the window through Xwayland, where the pen works (PHOTOCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
return DisplaySession::X11;
}
session
}
/// [`display_session`]'s decision; the system probes run only when the session asks for them.
#[cfg(any(target_os = "linux", test))]
pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<String>, pen: impl FnOnce() -> bool, x11_libs: impl FnOnce() -> bool) -> bool {
let set = |k: &str| var(k).is_some_and(|v| !v.is_empty() && v != "0");
session == DisplaySession::Wayland && !set("PHOTOCRAFT_NATIVE_WAYLAND") && set("DISPLAY") && pen() && x11_libs()
}
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key a pen tablet's stylus, a pen display's or
/// a pen-enabled touchscreen's reports (libinput treats such a device as a tablet tool).
#[cfg(any(target_os = "linux", test))]
const BTN_TOOL_PEN: u32 = 0x140;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
#[cfg(any(target_os = "linux", test))]
pub fn has_pen(devices: &str) -> bool {
let bits = usize::BITS;
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
})
}
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
/// X app would outlive a mouse stroke here), so nothing starts.
#[cfg(target_os = "linux")]
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
if display != Some(DisplayKind::X11) {
log::info!("tablet pressure: not available on this display server ({display:?})");
return;
}
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
log::warn!("{e}");
}
}
/// The windowing system eframe ended up on.
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayKind {
X11,
Wayland,
Other,
}
#[cfg(target_os = "linux")]
impl DisplayKind {
/// From the creation context's display handle.
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
Some(match cc.display_handle().ok()?.as_raw() {
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
_ => DisplayKind::Other,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_tablet::{Update, appkit, xi};
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
use photocraft_ui_egui::state::Tool;
use serde_json::{Value, json};
#[test]
fn samples_map_and_sanitize() {
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
let feed = StylusFeed::default();
let f = sink(feed.clone());
f(Some(Sample { pressure: 0.5, ..Default::default() }));
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
f(None);
assert_eq!(feed.get(), None);
}
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let feed = app.stylus.feed.clone();
let sink = sink(feed);
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
app.ui.tool = Tool::Brush;
let m = egui::Modifiers::NONE;
let n = 20;
for i in 0..=n {
let t = i as f32 / n as f32;
sink(next(0.1 + 0.9 * t));
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
let pressure = app.stylus.pressure();
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
tool_event(&mut app, ev, m);
}
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
sink(None);
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
// half-width there.
let mut dark = |x: i32, y: i32| -> f64 {
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
1.0 - px[0].as_f64().unwrap()
};
let (light, heavy) = (dark(50, 40), dark(200, 40));
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
(light, light_w, heavy, heavy_w)
}
#[test]
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
let mut st = appkit::State::default();
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
let e =
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
match st.handle(&e) {
Update::Set(s) => s,
Update::Keep => None,
}
});
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
let mut st = appkit::State::default();
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
match st.handle(&e) {
Update::Set(s) => s,
Update::Keep => None,
}
});
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
}
#[test]
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
let mut st = xi::State::default();
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
Update::Set(s) => s,
Update::Keep => None,
});
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, BTN_TOOL_PEN) and of the same laptop without it.
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
N: Name=\"AT Translated Set 2 keyboard\"\n\
H: Handlers=sysrq kbd event3 leds\n\
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
\n\
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
H: Handlers=mouse1 event7\n\
B: KEY=e520 10000 0 0 0 0\n\
B: ABS=2e0800000000003\n\
\n\
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
N: Name=\"Wacom Intuos Pro M Pen\"\n\
H: Handlers=mouse2 event10\n\
B: KEY=1c03 0 0 0 0 0\n\
B: ABS=1000f000003\n";
#[test]
fn a_pen_is_found_by_its_btn_tool_pen_key() {
assert!(has_pen(WITH_PEN));
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
// Short or malformed bitmaps are no pen, never a panic.
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(keys), "{keys:?}");
}
}
#[test]
fn a_pen_on_wayland_opens_the_window_through_xwayland() {
use std::collections::HashMap;
let env = |pairs: &[(&str, &str)]| {
let m: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k: &str| m.get(k).cloned()
};
let wayland = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")];
assert!(pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || true, || true));
// No pen, no Xwayland, no X11 libraries, or the user keeps Wayland: native Wayland.
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || false, || true));
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&[("WAYLAND_DISPLAY", "wayland-0")]), || true, || true));
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&wayland), || true, || false));
let native = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("PHOTOCRAFT_NATIVE_WAYLAND", "1")];
assert!(!pen_needs_xwayland(DisplaySession::Wayland, env(&native), || true, || true));
let zero = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("PHOTOCRAFT_NATIVE_WAYLAND", "0")];
assert!(pen_needs_xwayland(DisplaySession::Wayland, env(&zero), || true, || true));
// X11 already gets the pen; the probes don't even run off Wayland.
assert!(!pen_needs_xwayland(DisplaySession::X11, env(&[("DISPLAY", ":0")]), || panic!("probed"), || panic!("probed")));
}
#[test]
fn use_tablet_pressure_off_paints_like_a_mouse() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
// The canvas copies the preference each frame; do what it does.
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
assert_eq!(app.stylus.pressure(), 1.0);
assert_eq!(app.stylus.sample(), None);
}
#[test]
fn eraser_end_switches_to_the_eraser_and_back() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let sink = sink(app.stylus.feed.clone());
let mut st = appkit::State::default();
let mut send = |e: appkit::RawEvent| {
if let Update::Set(s) = st.handle(&e) {
sink(s);
}
};
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
app.ui.tool = Tool::Brush;
send(prox(appkit::device::PEN, true));
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
send(prox(appkit::device::PEN, false));
send(prox(appkit::device::ERASER, true));
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
// Mouse moves in between change nothing; the tip coming back restores the Brush.
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
send(prox(appkit::device::PEN, true));
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush);
}
}

View File

@ -0,0 +1,133 @@
//! The saved window geometry and egui layout (`ui.ron`, written by eframe's persistence).
//!
//! eframe restores this state while it creates the window, before PhotoCraft's code runs, and
//! some values it does not check abort startup: a zoom factor of zero, NaN or 1e30 fails a
//! `dpi` assertion, and a NaN or huge window size fails surface creation. A truncated or
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
//! holding such values (the window and panels open at their defaults; nothing else is lost).
//! A file that isn't RON at all is left alone: eframe already ignores it.
use std::path::Path;
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
const MAX_COORD: f64 = 1.0e6;
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
/// 300% on a 1× display is 3).
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
pub fn sanitize(path: Option<&Path>) {
let Some(path) = path else { return };
let Ok(text) = std::fs::read_to_string(path) else { return };
if let Err(why) = check(&text) {
log::warn!("ignoring saved window layout {}: {why}", path.display());
if let Err(e) = std::fs::remove_file(path) {
log::warn!("could not remove {}: {e}", path.display());
}
}
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
for (key, value) in &kv {
// eframe also skips an entry that doesn't parse.
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
floats_sane(&v, 0).map_err(|e| format!("{key}: {e}"))?;
let zoom = (key == "egui").then(|| field(&v, "options").and_then(|o| field(o, "zoom_factor")).and_then(as_f64)).flatten();
if let Some(z) = zoom.filter(|z| !ZOOM.contains(z)) {
return Err(format!("egui: zoom factor {z}"));
}
}
Ok(())
}
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
if depth > 64 {
return Err("nested too deeply".into());
}
match v {
ron::Value::Number(_) => match as_f64(v) {
Some(f) if !f.is_finite() || f.abs() > MAX_COORD => Err(format!("value {f}")),
_ => Ok(()),
},
ron::Value::Option(Some(inner)) => floats_sane(inner, depth + 1),
ron::Value::Seq(items) => items.iter().try_for_each(|i| floats_sane(i, depth + 1)),
ron::Value::Map(m) => m.iter().try_for_each(|(k, v)| floats_sane(k, depth + 1).and_then(|()| floats_sane(v, depth + 1))),
_ => Ok(()),
}
}
/// A float (integers such as egui's hashed ids are not coordinates).
fn as_f64(v: &ron::Value) -> Option<f64> {
match v {
ron::Value::Number(ron::value::Number::F32(f)) => Some(f64::from(f.get())),
ron::Value::Number(ron::value::Number::F64(f)) => Some(f.get()),
_ => None,
}
}
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
match v {
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::check;
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
format!("{{\"window\":{window:?},\"egui\":{egui:?}}}")
}
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
#[test]
fn a_saved_layout_is_kept() {
assert_eq!(check(&file(WINDOW, "1.0")), Ok(()));
assert_eq!(check(&file(WINDOW, "0.375")), Ok(()));
}
#[test]
fn values_that_abort_startup_are_rejected() {
// Each of these aborted the app at launch before the check (dpi / wgpu assertions).
for zoom in ["0.0", "NaN", "1e30", "-1.0"] {
assert!(check(&file(WINDOW, zoom)).is_err(), "zoom {zoom}");
}
for size in ["(x:NaN,y:760.0)", "(x:1100.0,y:1e30)", "(x:inf,y:1.0)"] {
let w = WINDOW.replace("(x:1100.0,y:760.0)", size);
assert!(check(&file(&w, "1.0")).is_err(), "size {size}");
}
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
for text in ["", "not ron {{{", "{\"window\":\"(((\"}", "[1,2,3]"] {
assert_eq!(check(text), Ok(()), "{text:?}");
}
let deep = format!("{{\"egui\":{:?}}}", "[".repeat(500) + &"]".repeat(500));
let _ = check(&deep);
}
#[test]
fn sanitize_removes_only_a_bad_file() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("good.ron");
let bad = dir.join("bad.ron");
std::fs::write(&good, file(WINDOW, "1.0")).unwrap();
std::fs::write(&bad, file(WINDOW, "0.0")).unwrap();
super::sanitize(Some(&good));
super::sanitize(Some(&bad));
super::sanitize(Some(&dir.join("missing.ron")));
super::sanitize(None);
assert!(good.exists() && !bad.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -0,0 +1,73 @@
//! `--control` setup failures must exit non-zero (#1816): a launcher that sees
//! status 0 believes the control server is running when it is not.
//!
//! These tests run the real binary as a subprocess and assert the exit code,
//! covering both failure paths in `main.rs`: control authentication and the
//! automation workspace.
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
/// workspace check instead of failing on token validation first.
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// Run `photocraft` with the given arguments in an isolated config dir and
/// return the exit status and stderr. Each call gets its own config dir (tests run in
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
// Isolate from the host environment so the test is reproducible.
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
.env_remove("PHOTOCRAFT_AUTOMATION_READ_ROOT")
.env_remove("PHOTOCRAFT_AUTOMATION_WRITE_ROOT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn both_token_and_token_file_exits_with_status_2() {
// `server_token` rejects the combination of a supplied token and a token file.
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let token_file = dir.join("token.txt");
std::fs::write(&token_file, VALID_TOKEN).unwrap();
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn missing_automation_read_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the read root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
#[test]
fn missing_automation_write_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the write root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}

View File

@ -0,0 +1,172 @@
//! The macOS menu bar end to end, on the main thread (`harness = false`): install it through the
//! real muda backend (`src/mac_menu.rs`), read AppKit's menu bar back, open a document and see the
//! items update in place, then choose items the two ways AppKit can: a key equivalent (a real ⌘R
//! key-down through `-[NSMenu performKeyEquivalent:]`) must reach egui as the key press it was, and
//! a click (`-[NSMenu performActionForItemAtIndex:]`) must run as a click, also when the click is
//! ↩ inside the open menu.
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
// unit tests compile here too without a harness to run them).
#[cfg(target_os = "macos")]
#[path = "../src/mac_menu.rs"]
#[allow(unused_imports, dead_code)]
mod mac_menu;
#[cfg(target_os = "macos")]
fn main() {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags, NSEventSubtype, NSEventType, NSMenu};
use objc2_foundation::{NSPoint, NSString};
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::native_menu;
use serde_json::json;
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let nsapp = NSApplication::sharedApplication(mtm);
nsapp.finishLaunching();
let titles = |menu: &NSMenu| -> Vec<String> {
(0..menu.numberOfItems()).filter_map(|i| menu.itemAtIndex(i)).filter(|it| !it.isSeparatorItem()).map(|it| it.title().to_string()).collect()
};
let submenu = |menu: &NSMenu, title: &str| -> Retained<NSMenu> {
(0..menu.numberOfItems())
.filter_map(|i| menu.itemAtIndex(i))
.find(|it| it.title().to_string() == title)
.and_then(|it| it.submenu())
.unwrap_or_else(|| panic!("no {title} submenu in {:?}", titles(menu)))
};
let index = |menu: &NSMenu, title: &str| -> isize {
(0..menu.numberOfItems())
.find(|&i| menu.itemAtIndex(i).is_some_and(|it| it.title().to_string() == title))
.unwrap_or_else(|| panic!("no {title} in {:?}", titles(menu)))
};
let main_menu = || nsapp.mainMenu().expect("a main menu");
// Make `event` AppKit's current event, as the run loop does before dispatching it.
let dequeue = |event: &NSEvent| {
nsapp.postEvent_atStart(event, true);
let mode = NSString::from_str("kCFRunLoopDefaultMode");
let got = nsapp.nextEventMatchingMask_untilDate_inMode_dequeue(NSEventMask::Any, None, &mode, true).expect("the posted event");
assert_eq!(got.r#type(), event.r#type());
got
};
let key_down = |chars: &str, code: u16, flags: NSEventModifierFlags| {
let s = NSString::from_str(chars);
NSEvent::keyEventWithType_location_modifierFlags_timestamp_windowNumber_context_characters_charactersIgnoringModifiers_isARepeat_keyCode(
NSEventType::KeyDown,
NSPoint::new(0.0, 0.0),
flags,
0.0,
0,
None,
&s,
&s,
false,
code,
)
.expect("a key event")
};
let other = || {
NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
NSEventType::ApplicationDefined,
NSPoint::new(0.0, 0.0),
NSEventModifierFlags::empty(),
0.0,
0,
None,
NSEventSubtype::ApplicationActivated.0,
0,
0,
)
.expect("an application-defined event")
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());
let ctx = egui::Context::default();
let frame = |app: &mut PhotocraftApp, raw: egui::RawInput| {
let mut out = ctx.run_ui(raw, |ui| {
native_menu::run(app, ui.ctx());
photocraft_ui_egui::shortcuts::handle(app, ui.ctx());
native_menu::sync(app, ui.ctx());
});
out.textures_delta.clear();
};
app.services.native_menu = mac_menu::install(&ctx, &app);
assert!(app.services.native_menu.is_some(), "the menu bar installed");
// The Mac layout, as AppKit has it.
let bar = main_menu();
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
assert_eq!(tops, ["PhotoCraft", "File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]);
let app_menu = submenu(&bar, "PhotoCraft");
let names = titles(&app_menu);
for want in ["About PhotoCraft", "Settings", "Language", "Appearance", "Hide PhotoCraft", "Quit PhotoCraft"] {
assert!(names.iter().any(|n| n == want), "{want} missing from the app menu: {names:?}");
}
let quit = app_menu.itemAtIndex(index(&app_menu, "Quit PhotoCraft")).expect("Quit");
assert_eq!(quit.keyEquivalent().to_string(), "q");
assert_eq!(quit.keyEquivalentModifierMask(), NSEventModifierFlags::Command);
assert!(!titles(&submenu(&bar, "File")).iter().any(|n| n == "Exit"), "Exit moved to the app menu as Quit");
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
assert_eq!(nsapp.helpMenu().map(|m| m.title().to_string()).as_deref(), Some("Help"), "AppKit knows the Help menu");
// Opening a document enables Layer › New › Layer… in place: same NSMenu, no rebuild.
let new_layer = |bar: &NSMenu| {
let new = submenu(&submenu(bar, "Layer"), "New");
new.itemAtIndex(index(&new, "Layer…")).expect("Layer…").isEnabled()
};
assert!(!new_layer(&bar), "no document: New Layer is disabled");
app.run("file.new", json!({"width": 64, "height": 48})).expect("a document");
frame(&mut app, egui::RawInput::default());
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "opening a document updated the menu, not rebuilt it");
assert!(new_layer(&main_menu()), "with a document New Layer is enabled");
// A key equivalent: ⌘R reaches egui as ⌘R and toggles View › Rulers once.
let rulers = app.ui.extras.rulers;
let press = dequeue(&key_down("r", 15, NSEventModifierFlags::Command));
assert!(main_menu().performKeyEquivalent(&press), "⌘R is View › Rulers' key equivalent");
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
let keys: Vec<_> = raw
.events
.iter()
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
.collect();
// AppKit took only the key-down; winit delivers the key-up itself (#1638).
assert_eq!(keys, [(egui::Key::R, true, true)], "the key press");
frame(&mut app, raw);
let ran: Vec<String> = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect();
assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched");
assert_eq!(app.ui.extras.rulers, !rulers, "⌘R toggled Rulers once");
// A click: View › Rulers chosen with the mouse runs the command.
let view = submenu(&main_menu(), "View");
let at = index(&view, "Rulers");
dequeue(&other());
view.performActionForItemAtIndex(at);
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
assert!(raw.events.is_empty(), "a click is no key press: {:?}", raw.events);
frame(&mut app, raw);
assert_eq!(app.ui.extras.rulers, rulers, "the click toggled Rulers back");
// ↩ inside the open menu is a key-down without ⌘: still a click.
dequeue(&key_down("\r", 36, NSEventModifierFlags::empty()));
view.performActionForItemAtIndex(at);
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
assert!(raw.events.is_empty(), "↩ in the menu is a click: {:?}", raw.events);
frame(&mut app, raw);
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
println!("mac_menu_appkit: ok");
}
#[cfg(not(target_os = "macos"))]
fn main() {}

View File

@ -0,0 +1,47 @@
//! A failed Linux desktop launch must not poison the next launch's graphics policy.
#![cfg(target_os = "linux")]
#[test]
fn missing_display_does_not_leave_a_gpu_crash_marker() {
let dir = std::env::temp_dir().join(format!("photocraft-no-display-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let prefs = br#"{"performance":{"gpuBackend":"vulkan","useGpu":true}}"#;
std::fs::write(dir.join("preferences.json"), prefs).unwrap();
let error = launch_without_display(&dir);
assert!(!dir.join("gpu-starting.json").exists(), "a display error is not a GPU crash: {error}");
assert_eq!(std::fs::read(dir.join("preferences.json")).unwrap(), prefs);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn missing_display_preserves_existing_gpu_crash_evidence() {
let dir = std::env::temp_dir().join(format!("photocraft-no-display-prior-crash-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let marker = serde_json::json!({
"backend": "vulkan", "adapter": "Test GPU", "adapterBackend": "vulkan",
"driver": "test", "version": "test"
});
std::fs::write(dir.join("gpu-starting.json"), serde_json::to_vec(&marker).unwrap()).unwrap();
launch_without_display(&dir);
let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("gpu-starting.json")).unwrap()).unwrap();
assert_eq!(restored, marker, "a windowing error must not erase or advance prior GPU crash evidence");
std::fs::remove_dir_all(&dir).unwrap();
}
fn launch_without_display(dir: &std::path::Path) -> String {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.env("PHOTOCRAFT_CONFIG_DIR", dir)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WGPU_BACKEND")
.env_remove("PHOTOCRAFT_GPU_STARTUP_FAILURE")
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.output()
.unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "a desktop cannot open without a display");
assert!(error.contains("WAYLAND_DISPLAY") && error.contains("DISPLAY"), "expected a display error, got: {error}");
error.into_owned()
}

View File

@ -36,7 +36,10 @@ craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.tx
- `hicolor/<size>/apps/ai.storyteller.photocraft.png` (16–512) and `hicolor/scalable/...svg`:
Linux icon theme.
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/main.rs`).
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/app_icon.rs`,
`main.rs`). On Windows the taskbar shows the icon of the Start Menu shortcut that launches the
exe, so the MSI's `<Icon Id>` keeps the `.exe` extension (ICE50); `app_icon.rs` documents the
details and tests the `.ico` sizes and the WiX icon references.
## Regenerate

View File

@ -0,0 +1,334 @@
assets/icons/lasso-magnetic.svg
Adapted from "Magnetic Lasso Tool" by Lil' Seal (failureiscool) from Noun Project,
https://thenounproject.com/icon/magnetic-lasso-tool-177513/
licensed under Creative Commons Attribution 3.0 Unported (CC BY 3.0),
https://creativecommons.org/licenses/by/3.0/
Changes by PhotoCraft contributors: redrawn as a 24 x 24 SVG on PhotoCraft's toolbar grid, with
round-capped strokes in place of the original line weights, so it can be tinted and rasterized
like the other toolbar icons.
The full text of the licence follows.
--------------------------------------------------------------------------------
Creative Commons Legal Code
Attribution 3.0 Unported
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
DAMAGES RESULTING FROM ITS USE.
License
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
CONDITIONS.
1. Definitions
a. "Adaptation" means a work based upon the Work, or upon the Work and
other pre-existing works, such as a translation, adaptation,
derivative work, arrangement of music or other alterations of a
literary or artistic work, or phonogram or performance and includes
cinematographic adaptations or any other form in which the Work may be
recast, transformed, or adapted including in any form recognizably
derived from the original, except that a work that constitutes a
Collection will not be considered an Adaptation for the purpose of
this License. For the avoidance of doubt, where the Work is a musical
work, performance or phonogram, the synchronization of the Work in
timed-relation with a moving image ("synching") will be considered an
Adaptation for the purpose of this License.
b. "Collection" means a collection of literary or artistic works, such as
encyclopedias and anthologies, or performances, phonograms or
broadcasts, or other works or subject matter other than works listed
in Section 1(f) below, which, by reason of the selection and
arrangement of their contents, constitute intellectual creations, in
which the Work is included in its entirety in unmodified form along
with one or more other contributions, each constituting separate and
independent works in themselves, which together are assembled into a
collective whole. A work that constitutes a Collection will not be
considered an Adaptation (as defined above) for the purposes of this
License.
c. "Distribute" means to make available to the public the original and
copies of the Work or Adaptation, as appropriate, through sale or
other transfer of ownership.
d. "Licensor" means the individual, individuals, entity or entities that
offer(s) the Work under the terms of this License.
e. "Original Author" means, in the case of a literary or artistic work,
the individual, individuals, entity or entities who created the Work
or if no individual or entity can be identified, the publisher; and in
addition (i) in the case of a performance the actors, singers,
musicians, dancers, and other persons who act, sing, deliver, declaim,
play in, interpret or otherwise perform literary or artistic works or
expressions of folklore; (ii) in the case of a phonogram the producer
being the person or legal entity who first fixes the sounds of a
performance or other sounds; and, (iii) in the case of broadcasts, the
organization that transmits the broadcast.
f. "Work" means the literary and/or artistic work offered under the terms
of this License including without limitation any production in the
literary, scientific and artistic domain, whatever may be the mode or
form of its expression including digital form, such as a book,
pamphlet and other writing; a lecture, address, sermon or other work
of the same nature; a dramatic or dramatico-musical work; a
choreographic work or entertainment in dumb show; a musical
composition with or without words; a cinematographic work to which are
assimilated works expressed by a process analogous to cinematography;
a work of drawing, painting, architecture, sculpture, engraving or
lithography; a photographic work to which are assimilated works
expressed by a process analogous to photography; a work of applied
art; an illustration, map, plan, sketch or three-dimensional work
relative to geography, topography, architecture or science; a
performance; a broadcast; a phonogram; a compilation of data to the
extent it is protected as a copyrightable work; or a work performed by
a variety or circus performer to the extent it is not otherwise
considered a literary or artistic work.
g. "You" means an individual or entity exercising rights under this
License who has not previously violated the terms of this License with
respect to the Work, or who has received express permission from the
Licensor to exercise rights under this License despite a previous
violation.
h. "Publicly Perform" means to perform public recitations of the Work and
to communicate to the public those public recitations, by any means or
process, including by wire or wireless means or public digital
performances; to make available to the public Works in such a way that
members of the public may access these Works from a place and at a
place individually chosen by them; to perform the Work to the public
by any means or process and the communication to the public of the
performances of the Work, including by public digital performance; to
broadcast and rebroadcast the Work by any means including signs,
sounds or images.
i. "Reproduce" means to make copies of the Work by any means including
without limitation by sound or visual recordings and the right of
fixation and reproducing fixations of the Work, including storage of a
protected performance or phonogram in digital form or other electronic
medium.
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
limit, or restrict any uses free from copyright or rights arising from
limitations or exceptions that are provided for in connection with the
copyright protection under copyright law or other applicable laws.
3. License Grant. Subject to the terms and conditions of this License,
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
perpetual (for the duration of the applicable copyright) license to
exercise the rights in the Work as stated below:
a. to Reproduce the Work, to incorporate the Work into one or more
Collections, and to Reproduce the Work as incorporated in the
Collections;
b. to create and Reproduce Adaptations provided that any such Adaptation,
including any translation in any medium, takes reasonable steps to
clearly label, demarcate or otherwise identify that changes were made
to the original Work. For example, a translation could be marked "The
original work was translated from English to Spanish," or a
modification could indicate "The original work has been modified.";
c. to Distribute and Publicly Perform the Work including as incorporated
in Collections; and,
d. to Distribute and Publicly Perform Adaptations.
e. For the avoidance of doubt:
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
which the right to collect royalties through any statutory or
compulsory licensing scheme cannot be waived, the Licensor
reserves the exclusive right to collect such royalties for any
exercise by You of the rights granted under this License;
ii. Waivable Compulsory License Schemes. In those jurisdictions in
which the right to collect royalties through any statutory or
compulsory licensing scheme can be waived, the Licensor waives the
exclusive right to collect such royalties for any exercise by You
of the rights granted under this License; and,
iii. Voluntary License Schemes. The Licensor waives the right to
collect royalties, whether individually or, in the event that the
Licensor is a member of a collecting society that administers
voluntary licensing schemes, via that society, from any exercise
by You of the rights granted under this License.
The above rights may be exercised in all media and formats whether now
known or hereafter devised. The above rights include the right to make
such modifications as are technically necessary to exercise the rights in
other media and formats. Subject to Section 8(f), all rights not expressly
granted by Licensor are hereby reserved.
4. Restrictions. The license granted in Section 3 above is expressly made
subject to and limited by the following restrictions:
a. You may Distribute or Publicly Perform the Work only under the terms
of this License. You must include a copy of, or the Uniform Resource
Identifier (URI) for, this License with every copy of the Work You
Distribute or Publicly Perform. You may not offer or impose any terms
on the Work that restrict the terms of this License or the ability of
the recipient of the Work to exercise the rights granted to that
recipient under the terms of the License. You may not sublicense the
Work. You must keep intact all notices that refer to this License and
to the disclaimer of warranties with every copy of the Work You
Distribute or Publicly Perform. When You Distribute or Publicly
Perform the Work, You may not impose any effective technological
measures on the Work that restrict the ability of a recipient of the
Work from You to exercise the rights granted to that recipient under
the terms of the License. This Section 4(a) applies to the Work as
incorporated in a Collection, but this does not require the Collection
apart from the Work itself to be made subject to the terms of this
License. If You create a Collection, upon notice from any Licensor You
must, to the extent practicable, remove from the Collection any credit
as required by Section 4(b), as requested. If You create an
Adaptation, upon notice from any Licensor You must, to the extent
practicable, remove from the Adaptation any credit as required by
Section 4(b), as requested.
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
Collections, You must, unless a request has been made pursuant to
Section 4(a), keep intact all copyright notices for the Work and
provide, reasonable to the medium or means You are utilizing: (i) the
name of the Original Author (or pseudonym, if applicable) if supplied,
and/or if the Original Author and/or Licensor designate another party
or parties (e.g., a sponsor institute, publishing entity, journal) for
attribution ("Attribution Parties") in Licensor's copyright notice,
terms of service or by other reasonable means, the name of such party
or parties; (ii) the title of the Work if supplied; (iii) to the
extent reasonably practicable, the URI, if any, that Licensor
specifies to be associated with the Work, unless such URI does not
refer to the copyright notice or licensing information for the Work;
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
a credit identifying the use of the Work in the Adaptation (e.g.,
"French translation of the Work by Original Author," or "Screenplay
based on original Work by Original Author"). The credit required by
this Section 4 (b) may be implemented in any reasonable manner;
provided, however, that in the case of a Adaptation or Collection, at
a minimum such credit will appear, if a credit for all contributing
authors of the Adaptation or Collection appears, then as part of these
credits and in a manner at least as prominent as the credits for the
other contributing authors. For the avoidance of doubt, You may only
use the credit required by this Section for the purpose of attribution
in the manner set out above and, by exercising Your rights under this
License, You may not implicitly or explicitly assert or imply any
connection with, sponsorship or endorsement by the Original Author,
Licensor and/or Attribution Parties, as appropriate, of You or Your
use of the Work, without the separate, express prior written
permission of the Original Author, Licensor and/or Attribution
Parties.
c. Except as otherwise agreed in writing by the Licensor or as may be
otherwise permitted by applicable law, if You Reproduce, Distribute or
Publicly Perform the Work either by itself or as part of any
Adaptations or Collections, You must not distort, mutilate, modify or
take other derogatory action in relation to the Work which would be
prejudicial to the Original Author's honor or reputation. Licensor
agrees that in those jurisdictions (e.g. Japan), in which any exercise
of the right granted in Section 3(b) of this License (the right to
make Adaptations) would be deemed to be a distortion, mutilation,
modification or other derogatory action prejudicial to the Original
Author's honor and reputation, the Licensor will waive or not assert,
as appropriate, this Section, to the fullest extent permitted by the
applicable national law, to enable You to reasonably exercise Your
right under Section 3(b) of this License (right to make Adaptations)
but not otherwise.
5. Representations, Warranties and Disclaimer
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
7. Termination
a. This License and the rights granted hereunder will terminate
automatically upon any breach by You of the terms of this License.
Individuals or entities who have received Adaptations or Collections
from You under this License, however, will not have their licenses
terminated provided such individuals or entities remain in full
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
survive any termination of this License.
b. Subject to the above terms and conditions, the license granted here is
perpetual (for the duration of the applicable copyright in the Work).
Notwithstanding the above, Licensor reserves the right to release the
Work under different license terms or to stop distributing the Work at
any time; provided, however that any such election will not serve to
withdraw this License (or any other license that has been, or is
required to be, granted under the terms of this License), and this
License will continue in full force and effect unless terminated as
stated above.
8. Miscellaneous
a. Each time You Distribute or Publicly Perform the Work or a Collection,
the Licensor offers to the recipient a license to the Work on the same
terms and conditions as the license granted to You under this License.
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
offers to the recipient a license to the original Work on the same
terms and conditions as the license granted to You under this License.
c. If any provision of this License is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this License, and without further action
by the parties to this agreement, such provision shall be reformed to
the minimum extent necessary to make such provision valid and
enforceable.
d. No term or provision of this License shall be deemed waived and no
breach consented to unless such waiver or consent shall be in writing
and signed by the party to be charged with such waiver or consent.
e. This License constitutes the entire agreement between the parties with
respect to the Work licensed here. There are no understandings,
agreements or representations with respect to the Work not specified
here. Licensor shall not be bound by any additional provisions that
may appear in any communication from You. This License may not be
modified without the mutual written agreement of the Licensor and You.
f. The rights granted under, and the subject matter referenced, in this
License were drafted utilizing the terminology of the Berne Convention
for the Protection of Literary and Artistic Works (as amended on
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
and the Universal Copyright Convention (as revised on July 24, 1971).
These rights and subject matter take effect in the relevant
jurisdiction in which the License terms are sought to be enforced
according to the corresponding provisions of the implementation of
those treaty provisions in the applicable national law. If the
standard suite of rights granted under applicable copyright law
includes additional rights not granted under this License, such
additional rights are deemed to be included in the License; this
License is not intended to restrict the license of any rights under
applicable law.
Creative Commons Notice
Creative Commons is not a party to this License, and makes no warranty
whatsoever in connection with the Work. Creative Commons will not be
liable to You or any party on any legal theory for any damages
whatsoever, including without limitation any general, special,
incidental or consequential damages arising in connection to this
license. Notwithstanding the foregoing two (2) sentences, if Creative
Commons has expressly identified itself as the Licensor hereunder, it
shall have all rights and obligations of Licensor.
Except for the limited purpose of indicating to the public that the
Work is licensed under the CCPL, Creative Commons does not authorize
the use by either party of the trademark "Creative Commons" or any
related trademark or logo of Creative Commons without the prior
written consent of Creative Commons. Any permitted use will be in
compliance with Creative Commons' then-current trademark usage
guidelines, as may be published on its website or otherwise made
available upon request from time to time. For the avoidance of doubt,
this trademark restriction does not form part of this License.
Creative Commons may be contacted at https://creativecommons.org/.

View File

@ -0,0 +1,207 @@
Original painting symmetry axis icons by @shorbank (2026).
Files: symmetry-vertical.svg, symmetry-horizontal.svg, symmetry-dual.svg, symmetry-diagonal.svg.
SPDX-License-Identifier: MIT OR Apache-2.0
These original icons are available under either licence below, at your option.
MIT License
Copyright (c) 2026 shorbank
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@ -9,6 +9,6 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" />
</svg>

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 300 B

View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own clipping-mask cursor (not from an icon set): a hooked arrow down onto the layer below. -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own release-clipping-mask cursor: clip-below.svg unchanged, plus a small "no" badge (thinner strokes keep it legible at cursor size). -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
<circle cx="20" cy="4" r="3.25" stroke-width="1.5" />
<path d="m17.7 1.7 4.6 4.6" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
<rect x="16.5" y="16.5" width="4.5" height="4.5" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@ -0,0 +1,17 @@
<!-- Adapted from "Magnetic Lasso Tool" by Lil' Seal from Noun Project (CC BY 3.0): see LICENSE-noun-magnetic-lasso.txt -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 1.9H16.4A4.6 4.6 0 0 1 21 6.5V7.7A4.6 4.6 0 0 1 16.4 12.3H9V8.95H16.55A0.6 0.6 0 0 0 17.15 8.35V5.85A0.6 0.6 0 0 0 16.55 5.25H9ZM9.75 2.65H12.5V4.5H9.75ZM9.75 9.7H12.5V11.55H9.75Z" fill="currentColor" fill-rule="evenodd" stroke="none" />
<path d="M12.4 7.5 1.8 3.6l7.7 15.3-1.8 2.8" />
<ellipse cx="9.25" cy="16.65" rx="2.85" ry="2.2" />
<path d="m12.1 16.2 10.1-3-1.6-1" />
</svg>

After

Width:  |  Height:  |  Size: 729 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

15
assets/icons/link-2.svg Normal file
View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
<line x1="8" x2="16" y1="12" y2="12" />
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 29.33 30.81"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke-width="2.48" d="M1.33,11.42c0-4.05,3.28-7.33,7.33-7.33,2.08,0,4.06.88,5.45,2.43.28.3.75.32,1.06.04.01-.01.02-.02.04-.04,2.69-3.02,7.31-3.29,10.33-.6,1.57,1.4,2.46,3.4,2.45,5.5,0,3.05-2,5.33-4,7.33l-7.32,7.08c-.97,1.11-2.65,1.23-3.76.26-.08-.07-.16-.15-.24-.23l-7.34-7.11c-2-2-4-4.27-4-7.33" />
<line stroke-width="2.41" x1="14.57" y1="1.3" x2="14.76" y2="29.51" />
</svg>

After

Width:  |  Height:  |  Size: 577 B

14
assets/icons/pencil.svg Normal file
View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="25.445" y1="25.32" x2="5.365" y2="5.5" />
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="29.51" y1="15.31" x2="1.3" y2="15.5" />
<line x1="15.5" y1="29.51" x2="15.31" y2="1.3" />
</svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="29.51" y1="15.315" x2="1.3" y2="15.505" />
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 30.81 30.81"
fill="none"
stroke="currentColor"
stroke-width="2.41"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="15.315" y1="1.3" x2="15.505" y2="29.51" />
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -21,6 +21,8 @@
- [Overview](formats/overview.md)
- [PSD and PSB](formats/psd-psb.md)
- [Raster formats](formats/raster-formats.md)
- [SVG](formats/svg.md)
- [Affinity documents](formats/affinity.md)
- [The `.pcraft` format](formats/pcraft.md)
# Automation

View File

@ -5,7 +5,7 @@ The layer numbers below describe allowed dependency direction, not runtime privi
| Layer | Crates | Responsibility |
|---|---|---|
| L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles |
| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding |
| L0 standalone | `psd`, `codecs`, `affinity` | PSD/PSB and flat raster format parsing/encoding; bounded Affinity preview extraction |
| L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects |
| L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes |
| L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence |

View File

@ -13,7 +13,7 @@
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
```sh
photocraft-cli run input.psd \
@ -21,6 +21,46 @@ photocraft-cli run input.psd \
--out output.png
```
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
### Missing font warnings
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
If a type layer uses a font family that is not installed, `convert` and `run`
write a warning to standard error before exporting; they do not silently present
the fallback rendering as an exact match. `info --compact` includes the same
warning in its `warnings` JSON array, without writing it to stderr. `run`
also adds `warnings` to the JSON result for the command that first exposes
the missing family. A multi-command `run` reports each missing family once,
even if later commands still use it.
Warnings do not change the saved document's font declarations and do not stop
the operation. To list or replace the missing families intentionally, use
`type.resolveMissingFonts` on the document.
### Export selected adjustment layers as a .cube LUT
The engine command `file.export.colorLookupTables` creates a 3D LUT from an
identity RGB lattice. It can export all visible top-level adjustment layers
(the default), the current Layers panel selection, or a supplied list of
adjustment layer IDs. The `scope` and `layers` parameters are mutually
exclusive when `scope` is `selected`:
```json
{"size": 33, "scope": "selected", "path": "chosen-look.cube"}
```
For automation that does not use the Layers panel, supply `"layers": [10, 14]`
instead. Input ID order does not change the final color look: adjustments are
always baked in **document stacking order**, bottom to top. When `path` is
omitted, the command returns the .cube text in `cube`; both forms return
`size` and `layerCount`.
Valid LUT dimensions are 2–129 (same maximum as PhotoCraft's LUT reader).
Selected IDs must exist, be visible top-level adjustment layers, and contain
no duplicates; invalid selections report an error rather than silently
exporting a different look. Masks, vector masks and clipping are spatial
operations and cannot be represented faithfully by a color-only LUT, so the
baker omits them. Group-contained adjustments are not exported as independent
layers because that would lose their group's blending semantics.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.

View File

@ -30,3 +30,9 @@ The current server does not issue scoped sessions or distinguish trusted tools f
Filesystem capabilities are implemented. General tool and method capabilities remain
[proposed](../security/automation-security.md).
The five shared core tools are `command_list`, `command_run`, `command_batch`, `doc_inspect`
and `render_preview`. Existing documented tools, including `doc_render_preview`, remain
listed. `photocraft://document` and `photocraft://commands` expose live JSON. Direct headless
Render Video supports progress and cancellation using the granted write root; see
[`docs/mcp.md`](../../../docs/mcp.md) for arguments, cleanup and transport behavior.

View File

@ -11,7 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |

View File

@ -0,0 +1,31 @@
# Affinity documents
PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.afdesign`,
`.afphoto` and `.afpub` files from Affinity 1 and 2. Layers come in as editable PhotoCraft layers:
- pages and artboards become artboards (Publisher spreads sit side by side);
- layers and groups become groups;
- curves and shapes (rectangles, ellipses, polygons, stars and the other geometric shapes) become
shape layers with their fill, gradient and stroke;
- artistic and frame text becomes type layers, drawn with the fonts installed on your computer;
- placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers;
- vector and pixel masks, opacity, visibility, locks, names and blend modes are kept.
The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is
listed in a warning when the file opens, for example layer effects, adjustment layers and live
filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as
ellipses), master pages, and CMYK or Lab colour (converted to RGB without the document's profile).
Nothing is dropped without a warning.
PhotoCraft can't write Affinity files. An opened Affinity document has no save path: Save asks for
a new file (`.pcraft`, PSD, PNG…) and never writes over the Affinity file.
If the native data can't be read (a damaged file, or a layout PhotoCraft doesn't know), PhotoCraft
opens the small PNG preview Affinity stores in the file instead, as a single pixel layer named
“Affinity preview”, and the warning says why. That preview can't be placed or used by File ▸ Place,
stacks, batch processing or other imports that can't show the warning. Export PSD or PNG from
Affinity in that case.
See [the crate's notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md)
for the full list of what is imported, how the reader was written and validated, and its safety
limits.

View File

@ -1,13 +1,14 @@
# Format overview
External bytes enter PhotoCraft through three main paths:
External bytes enter PhotoCraft through four main paths:
- `photocraft-psd` parses and writes PSD and PSB structures.
- `photocraft-codecs` decodes and encodes flat raster formats.
- `photocraft-format` loads and saves the native `.pcraft` bundle.
- `photocraft-affinity` reads native Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): layers, artboards, vectors, text, images and masks; it does not write them.
`photocraft-io` maps PSD and flat images into the document model and exports documents back to those formats. It is the integration layer; parsing limits remain the responsibility of the underlying format crates.
Formats are a primary security boundary. File extension is not a trust signal, metadata can be attacker-controlled, and small compressed inputs can declare very large decoded outputs. Callers should use the bounded default APIs and preserve errors rather than retrying with unlimited limits.
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), [Affinity documents](affinity.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).

View File

@ -22,6 +22,8 @@ Default `LoadOptions` apply these maximums:
| One decompressed blob | 1 GiB |
| Total decompressed tiles and blobs | 16 GiB |
Layer groups may nest at most 100 levels (`MAX_GROUP_DEPTH`). Saving a deeper document fails with an error instead of writing a bundle that can't be opened, and a manifest whose JSON nesting goes beyond what that depth needs is rejected before it is parsed, so a hostile file can't exhaust the stack.
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.

View File

@ -1,9 +1,24 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## WebP
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
## Exporting documents
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
@ -12,11 +27,11 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP,
|---|---:|
| Maximum width | 262,144 pixels |
| Maximum height | 262,144 pixels |
| Maximum pixel count | 268,435,456 pixels |
| Maximum decoded allocation | 2 GiB |
| Maximum pixel count | 1,073,741,824 pixels |
| Maximum decoded allocation | 8 GiB on 64-bit targets; 2 GiB on 32-bit targets (including wasm32) |
Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `image`-based decoders, while other implementations perform explicit checked dimension/buffer validation. Tests cover bomb-like headers, truncation, bit flips, header overwrites, and random data.
`Limits::none()` exists for callers with a deliberate reason to relax policy. It remains bounded by representable memory sizes, but it removes the default resource policy and should not be used for untrusted input.
Animated containers are represented as a single decoded frame by the current codec API. Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
Animated containers and multi-page TIFFs are represented as a single decoded frame or page by the current codec API; opening one adds an import warning such as "only the first of 3 frames was imported". A JPEG cut off inside its image data opens with a warning that its data ends early (an error when it holds no image data at all). Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.

23
book/src/formats/svg.md Normal file
View File

@ -0,0 +1,23 @@
# SVG
An SVG opens as a document of shape layers, and places (File › Place Embedded / Place Linked) as a vector smart object. Both go through `photocraft-io`'s `svg` module; there is no SVG export.
## Opening
`usvg` parses the file (it is the same crate the UI's icons go through): CSS, `use`, units, nested transforms and `viewBox` are resolved, and text is shaped into outlines with the bundled craft fonts plus, on the desktop, the system's fonts. The resulting tree becomes layers in paint order:
| SVG | Layer |
|---|---|
| `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon` | A shape layer: cubic Bézier knots with the element's transform baked in, fill (colour and opacity, fill rule), stroke (width, colour, caps, joins, miter limit, dashes). |
| `g` with an `id`, opacity or blend mode | A layer group with that opacity and blend mode. Groups that only carry a transform are not layers. |
| `text` | A group of outline shapes named after the text. |
| Linear and radial gradient paints | The layer's own gradient fill with the stops and direction; the exact gradient geometry is not kept (a warning says so). |
| `clip-path`, `mask`, `filter`, pattern paints, `image` | Rasterised by `resvg` into a pixel layer over the element's bounding box, so the picture looks right while those parts are not editable as shapes (a warning names each kind). |
The canvas is the drawing's size in CSS px (from `width`/`height`, else the `viewBox`), RGB 8-bit, transparent where nothing is drawn. A drawing larger than 16 384 px on a side is scaled down to fit. Past 2 000 elements the drawing opens as a single pixel layer rather than thousands of shapes. Malformed SVG is an error, never a panic; `.svgz` is accepted by its extension and plain SVG is also recognised by content when the extension is missing.
## Placing
A placed SVG keeps its bytes as the smart object's source. Whenever the smart object is rendered (at placement, after Free Transform, when a smart filter changes) the drawing is rasterised at the placement's scale and then positioned, so it stays sharp at 1 000 % where a resampled raster would blur. Edit Contents opens the source as the shape-layer document above; saving it back stores the edited document, as for any smart object.
Limits: a single render is capped at 24 million pixels; warped smart objects and stacks render from the drawing's own size.

View File

@ -7,7 +7,10 @@ Automation is a privilege boundary because requests can cause filesystem access,
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB, active TCP connections to 16, and headless/MCP batches to 256 steps.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
are limited to 16, and headless/MCP batches to 256 steps.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
@ -16,22 +19,34 @@ Automation is a privilege boundary because requests can cause filesystem access,
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
retains its existing behavior.
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
downscaling; the 5 MiB PNG limit applies to the returned image.
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
- The bridge and UI request paths use timeouts.
- Engine commands are expected to reject invalid parameters without panicking.
- Remote filesystem methods use separately granted read and write directory capabilities.
- A save without a `path` (MCP `doc_save`, `serve` `doc.save`, the control channel's `app.save`)
writes back only to the document's own PSD, PSB or `.pcraft` file in its own format; any other
save needs an explicit `path`, so automation never flattens or converts over the file it opened.
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
traversal, alternate separators, Windows device names, malformed components, and link escapes
are rejected before file effects.
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
UI input reaches the same command policy, and automation-triggered file hooks do not run
user-configured script-event paths.
user-configured script-event paths. The policy also covers every command a command runs on its
own behalf (an action's steps, `file.automate.conditionalModeChange` running `image.mode.*`), so
an allowed command can't reach a denied one.
- `prefs.set` applies the engine's dotted-path normalization before checking for filesystem-bearing
preference sections, including paths with leading or repeated dots. It refuses whole-preferences
updates and those sections; individual safe preference keys remain available.
- Applying the Preferences dialog with `ui.dialog.apply` is deliberately refused over automation.
## Known limitations
- no per-client or per-tool capabilities;
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- one thread per accepted TCP connection;

View File

@ -39,15 +39,15 @@ A compact file declares oversized dimensions, excessive channels, nested descrip
### Filesystem authority abuse
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Current automation uses caller-supplied OS paths and has no workspace policy. This scenario is a known limitation.
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Remote filesystem methods use separately configured directory capabilities, and engine commands that still require ambient paths fail closed at the automation boundary. This is not yet a general per-command capability model, and trusted desktop operations can still use ambient paths.
### Local control takeover
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. Loopback reduces network exposure but does not authenticate the client.
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. A 256-bit bearer token is required before method dispatch, but authentication does not grant per-client or per-tool capabilities.
### Resource exhaustion
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Current automation transports lack explicit request, batch, and connection budgets.
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Request-line, connection, batch, and preview limits mitigate some cases; explicit JSON-depth, document-memory, and command-duration budgets remain absent.
### Supply-chain compromise

View File

@ -0,0 +1,943 @@
{
"schema": 1,
"repo": "storytold/photocraft",
"branch": "main",
"head": "aaa946b56df2d74d198c47e67dda35ddce0b3769",
"head_date": "2026-10-08T01:20:47Z",
"commits": 482,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 124,
"commits": 201,
"lines_added": 277845,
"lines_deleted": 22466,
"binary_added": 39,
"binary_deleted": 8,
"first_commit": "2026-09-30T06:12:04Z",
"last_commit": "2026-10-07T22:09:41Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 8,
"lines_added": 2707,
"lines_deleted": 54,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T04:57:38Z",
"last_commit": "2026-10-05T22:14:20Z"
},
{
"login": "Multipad-cyber",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 171,
"lines_deleted": 27,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T05:02:49Z",
"last_commit": "2026-10-06T11:43:37Z"
},
{
"login": "torstfugl",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 377,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T05:03:49Z",
"last_commit": "2026-10-04T16:21:25Z"
},
{
"login": "sommerper",
"people_entry": false,
"github_name": null,
"prs": 9,
"commits": 9,
"lines_added": 865,
"lines_deleted": 148,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T14:13:31Z",
"last_commit": "2026-10-07T11:12:58Z"
},
{
"login": "Gh0stlyKn1ght",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 3349,
"lines_deleted": 255,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T16:31:18Z",
"last_commit": "2026-10-05T05:02:40Z"
},
{
"login": "MikeeI",
"people_entry": false,
"github_name": null,
"prs": 22,
"commits": 22,
"lines_added": 753,
"lines_deleted": 178,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T04:59:38Z",
"last_commit": "2026-10-05T05:37:25Z"
},
{
"login": "tiredIsa",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 7086,
"lines_deleted": 493,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T05:01:36Z",
"last_commit": "2026-10-07T21:59:16Z"
},
{
"login": "Ni-zav",
"people_entry": false,
"github_name": null,
"prs": 12,
"commits": 12,
"lines_added": 488,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T05:01:49Z",
"last_commit": "2026-10-07T21:57:43Z"
},
{
"login": "kntrttm",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 4228,
"lines_deleted": 1172,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T05:02:52Z",
"last_commit": "2026-10-05T23:40:10Z"
},
{
"login": "kevin9327",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 15,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T08:14:40Z",
"last_commit": "2026-10-05T08:14:40Z"
},
{
"login": "mekanix",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 29,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T13:01:58Z",
"last_commit": "2026-10-05T13:01:58Z"
},
{
"login": "nuIIpointerexception",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 82,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T13:02:14Z",
"last_commit": "2026-10-05T13:02:14Z"
},
{
"login": "john8675309",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 89,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T13:58:45Z",
"last_commit": "2026-10-05T13:58:45Z"
},
{
"login": "CQMHV",
"people_entry": false,
"github_name": null,
"prs": 9,
"commits": 9,
"lines_added": 2841,
"lines_deleted": 23,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-05T22:13:51Z",
"last_commit": "2026-10-07T20:43:03Z"
},
{
"login": "kuishou68",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 6,
"lines_deleted": 1931,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T11:43:03Z",
"last_commit": "2026-10-06T11:43:03Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 199,
"lines_deleted": 25,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T11:44:08Z",
"last_commit": "2026-10-07T22:53:56Z"
},
{
"login": "bommerts",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 30,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T14:46:41Z",
"last_commit": "2026-10-06T14:46:41Z"
},
{
"login": "leonaci",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 202,
"lines_deleted": 26,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T15:27:54Z",
"last_commit": "2026-10-06T15:27:54Z"
},
{
"login": "juansebsol",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 800,
"lines_deleted": 118,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T15:28:09Z",
"last_commit": "2026-10-07T12:40:00Z"
},
{
"login": "WildfootW",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2027,
"lines_deleted": 17,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T15:28:23Z",
"last_commit": "2026-10-06T15:28:23Z"
},
{
"login": "Mawfyy",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 2213,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T15:28:37Z",
"last_commit": "2026-10-07T20:12:00Z"
},
{
"login": "petrleocompel",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2025,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T15:29:44Z",
"last_commit": "2026-10-06T15:29:44Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 35,
"commits": 35,
"lines_added": 6009,
"lines_deleted": 1253,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T00:59:14Z",
"last_commit": "2026-10-08T01:16:38Z"
},
{
"login": "Pedroj-64",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 141,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:28:01Z",
"last_commit": "2026-10-07T03:43:31Z"
},
{
"login": "floflo777",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 163,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:34:03Z",
"last_commit": "2026-10-07T11:05:38Z"
},
{
"login": "HEOJUNFO",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 55,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:34:17Z",
"last_commit": "2026-10-07T03:34:30Z"
},
{
"login": "Ar-maan05",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 840,
"lines_deleted": 111,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:34:44Z",
"last_commit": "2026-10-07T20:30:52Z"
},
{
"login": "agorevski",
"people_entry": false,
"github_name": null,
"prs": 30,
"commits": 30,
"lines_added": 3353,
"lines_deleted": 404,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:34:59Z",
"last_commit": "2026-10-07T21:14:03Z"
},
{
"login": "naumanAhmed3",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 14,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:38:32Z",
"last_commit": "2026-10-07T03:38:32Z"
},
{
"login": "ljkx",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 7478,
"lines_deleted": 178,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:43:18Z",
"last_commit": "2026-10-08T01:12:38Z"
},
{
"login": "carterwsmith",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 1213,
"lines_deleted": 152,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T04:32:23Z",
"last_commit": "2026-10-07T22:57:50Z"
},
{
"login": "coygeek",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T04:32:34Z",
"last_commit": "2026-10-07T04:32:34Z"
},
{
"login": "neversaynv3r",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 89,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T04:40:40Z",
"last_commit": "2026-10-07T04:40:40Z"
},
{
"login": "ianswope",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 82,
"lines_deleted": 9,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T09:26:01Z",
"last_commit": "2026-10-07T19:59:51Z"
},
{
"login": "TheSeanLavery",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 2205,
"lines_deleted": 66,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T10:11:06Z",
"last_commit": "2026-10-07T11:45:26Z"
},
{
"login": "mnmlyw",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 330,
"lines_deleted": 41,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T10:12:13Z",
"last_commit": "2026-10-08T01:20:47Z"
},
{
"login": "rosematcha",
"people_entry": false,
"github_name": null,
"prs": 12,
"commits": 12,
"lines_added": 1700,
"lines_deleted": 202,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T10:49:53Z",
"last_commit": "2026-10-08T00:18:27Z"
},
{
"login": "jason4short",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 8,
"lines_added": 1653,
"lines_deleted": 101,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T10:58:01Z",
"last_commit": "2026-10-07T22:02:34Z"
},
{
"login": "siraxe",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 276,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:03:49Z",
"last_commit": "2026-10-07T11:03:49Z"
},
{
"login": "arpitkjaiswal",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 19,
"lines_deleted": 19,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:06:21Z",
"last_commit": "2026-10-07T11:06:21Z"
},
{
"login": "EthanSuquet",
"people_entry": false,
"github_name": null,
"prs": 5,
"commits": 5,
"lines_added": 405,
"lines_deleted": 56,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:40:10Z",
"last_commit": "2026-10-07T20:50:09Z"
},
{
"login": "alexanderalber",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 1809,
"lines_deleted": 56,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:42:30Z",
"last_commit": "2026-10-07T22:59:39Z"
},
{
"login": "flyyangX",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 242,
"lines_deleted": 53,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:46:52Z",
"last_commit": "2026-10-07T11:46:52Z"
},
{
"login": "leblanc-simon",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2527,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:49:30Z",
"last_commit": "2026-10-07T11:49:30Z"
},
{
"login": "trokute",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2484,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:52:51Z",
"last_commit": "2026-10-07T11:52:51Z"
},
{
"login": "ha1des",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1328,
"lines_deleted": 161,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T13:02:58Z",
"last_commit": "2026-10-07T13:02:58Z"
},
{
"login": "neko233-com",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 2719,
"lines_deleted": 73,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T13:06:33Z",
"last_commit": "2026-10-07T20:52:55Z"
},
{
"login": "Sirsyorrz",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 61,
"lines_deleted": 7,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T13:11:35Z",
"last_commit": "2026-10-07T13:11:35Z"
},
{
"login": "Elvis33LE",
"people_entry": false,
"github_name": null,
"prs": 8,
"commits": 8,
"lines_added": 3929,
"lines_deleted": 91,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T13:11:45Z",
"last_commit": "2026-10-08T01:10:38Z"
},
{
"login": "cristianmoroaica",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 34,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T13:12:11Z",
"last_commit": "2026-10-07T13:12:11Z"
},
{
"login": "Webificio",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 232,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T13:12:44Z",
"last_commit": "2026-10-07T13:12:44Z"
},
{
"login": "mvanhorn",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 2710,
"lines_deleted": 168,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:09:46Z",
"last_commit": "2026-10-07T21:12:52Z"
},
{
"login": "gangldiego",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2592,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:11:38Z",
"last_commit": "2026-10-07T20:11:38Z"
},
{
"login": "kijoong-kim",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1337,
"lines_deleted": 539,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:32:47Z",
"last_commit": "2026-10-07T20:32:47Z"
},
{
"login": "Cyanoxide",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 9,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:34:38Z",
"last_commit": "2026-10-07T20:34:38Z"
},
{
"login": "tanish2k09",
"people_entry": false,
"github_name": null,
"prs": 10,
"commits": 10,
"lines_added": 389,
"lines_deleted": 94,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:39:39Z",
"last_commit": "2026-10-07T23:57:32Z"
},
{
"login": "jerrykimo",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 442,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:57:21Z",
"last_commit": "2026-10-07T20:57:21Z"
},
{
"login": "bfenty",
"people_entry": false,
"github_name": null,
"prs": 10,
"commits": 10,
"lines_added": 490,
"lines_deleted": 21,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T21:45:54Z",
"last_commit": "2026-10-08T01:07:50Z"
},
{
"login": "Luna161",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 89,
"lines_deleted": 15,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:00:59Z",
"last_commit": "2026-10-08T01:04:05Z"
},
{
"login": "abc2cb",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1197,
"lines_deleted": 27,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:40:24Z",
"last_commit": "2026-10-07T22:40:24Z"
},
{
"login": "remidesbois1",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1894,
"lines_deleted": 400,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T23:05:15Z",
"last_commit": "2026-10-07T23:05:15Z"
},
{
"login": "giallorosso84",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2188,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T23:13:01Z",
"last_commit": "2026-10-07T23:13:01Z"
},
{
"login": "louwers",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 2528,
"lines_deleted": 21,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T23:42:45Z",
"last_commit": "2026-10-07T23:42:45Z"
},
{
"login": "simonwjackson",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 3,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T00:55:27Z",
"last_commit": "2026-10-08T00:55:27Z"
},
{
"login": "ajsongpilot",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 42,
"lines_deleted": 16,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T01:09:04Z",
"last_commit": "2026-10-08T01:09:04Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 228,
"lines_added": 153184,
"lines_deleted": 22216
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 45,
"lines_added": 3896,
"lines_deleted": 278
},
{
"id": "github/copilot",
"company": "GitHub",
"model": "Copilot",
"version": "",
"commits": 6,
"lines_added": 1094,
"lines_deleted": 61
},
{
"id": "anthropic/claude-mythos/5.1",
"company": "Anthropic",
"model": "Claude Mythos",
"version": "5.1",
"commits": 2,
"lines_added": 6195,
"lines_deleted": 100
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 2,
"lines_added": 4161,
"lines_deleted": 1171
},
{
"id": "alibaba/qwen-flash/3.8",
"company": "Alibaba",
"model": "Qwen Flash",
"version": "3.8",
"commits": 1,
"lines_added": 25,
"lines_deleted": 5
},
{
"id": "anthropic/claude-haiku/4.5",
"company": "Anthropic",
"model": "Claude Haiku",
"version": "4.5",
"commits": 1,
"lines_added": 134,
"lines_deleted": 12
},
{
"id": "cognition/devin",
"company": "Cognition",
"model": "Devin",
"version": "",
"commits": 1,
"lines_added": 44,
"lines_deleted": 1
}
]
}

65
contributors/people.toml Normal file
View File

@ -0,0 +1,65 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.AlanRockefeller]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.Streetblock]
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.reynold-hu]
real_name = "Reynold Hu"
sync_github_name = true
[people.ArthurViniciusBA1]
real_name = "Arthur Vinicius"
[people.SckraT]
sync_github_name = true
[people.GamerWil26]
real_name = "Willy Foo"
display_name = "Willy Foo"
sync_github_name = false
[people.Elvis33LE]
sync_github_name = true
[people.stevanmarjanovic]
sync_github_name = true
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.maxm11]
sync_github_name = true
[people.skraus2]
sync_github_name = true

View File

@ -0,0 +1,22 @@
[package]
name = "photocraft-affinity"
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[features]
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9"
[dev-dependencies]
proptest = { workspace = true }

149
crates/affinity/README.md Normal file
View File

@ -0,0 +1,149 @@
# photocraft-affinity
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
and hands `photocraft-io` a document in pixels with every transform composed. It does not write
Affinity files.
## What is imported
`photocraft-io` maps the model to an 8-bit RGB document at the Affinity document's resolution:
| Affinity | PhotoCraft |
|---|---|
| Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart |
| Artboards (`ShpN` with `ABEn`), with name, solid background, any transform | artboards (nested ones as groups with a vector mask) |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | shape layers |
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
| Vector masks (`AdCh` curves and shapes) | vector masks |
| Pixel masks (`MRst`, attached or as a layer in a group) | layer masks |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | shape fill (converted to RGB) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradient fills |
| Strokes: weight, scale with object, dash pattern, alignment, caps, joins, miter limit | shape strokes |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | type layers (rendered with the fonts installed) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded smart objects |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | pixel layers |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
| Opacity, visibility, lock, names, blend modes | the same (Add as Linear Dodge) |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients, special shapes
(cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than
round, stars with rounded points, several fills or strokes on one object (one of each is used),
strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without
the document's profile), facing pages and the Average, Negation, Reflect, Glow and Erase blend
modes (as Normal).
Opened Affinity documents start without a save path: Save asks for a new copy and never writes
back over the Affinity file. When the native document can't be read at all, `photocraft-io` opens
the embedded PNG preview instead, as one pixel layer named “Affinity preview”, with a warning
that names the reason. That preview can't be placed or used by imports that can't show the
warning (smart-object decoding, stacks, batch, displacement maps, variables).
## Embedded preview
`preview(bytes)` reads only the thumbnail record the header's offset 24 points to (the same in
container versions 8 to 12); it never scans for PNG signatures. All integers are little-endian.
| Record offset | Size | Observed value |
|---|---:|---|
| 0 | 8 | `ffffffff` followed by `Thmb` |
| 8 | 4 | Record version 1 |
| 12 | 4 | PNG byte length + 13 |
| 16 | 4 | PNG start offset 29 |
| 20 | 4 | Zero |
| 24 | 4 | PNG byte length |
| 28 | 1 | PNG encoding 1 |
| 29 | declared length | Exactly one PNG |
The PNG is limited to 16 MiB and 4096 pixels a side. IHDR must come first and be unique, IDAT
chunks contiguous, an optional PLTE unique and before IDAT, every chunk CRC must match and IEND must
end the file; compressed metadata (`zTXt`, `iTXt`, `iCCP`), APNG chunks and unknown critical chunks
are rejected. The I/O adapter decodes it with a 128 MiB allocation limit.
## Provenance and clean-room
Affinity has no published file-format specification, so this reader is built only from a public
description of the format and from public files, never from Affinity itself. This is how
PhotoCraft already treats formats that are only partly documented: `photocraft-psd` fills the gaps
in Adobe's specification from the MIT-licensed psd-tools and ag-psd, and `photocraft-raw` recovered
Nikon's compression tables by black-box analysis of CC0 sample files (see their READMEs).
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
disassembled for this work, and nothing from an Affinity installation (program code, resources,
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
is a document its author published.
* **A public, permissively licensed description.** The container and object-stream layout was
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
as Inkscape's Affinity extension) was read.
* **Every structure checked against public files.** afread doesn't say how its author learned the
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
the object stream names its own type, so a misread layout fails loudly instead of producing
plausible wrong data.
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens
all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
files, which are CC0, MIT or Apache-2.0 only.
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
files are fetched at pinned commits and sha256-verified, never committed.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
The same reader is shared, as an independent copy, with VectorCraft's `vectorcraft-affinity`.
## Validation
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
and the I/O import (`import_preview`); CI's fuzz job runs `preview` and `import_preview`:
```sh
cd crates/affinity/fuzz
cargo +nightly fuzz run stream -- -max_total_time=600
cargo +nightly fuzz run import_preview --features io -- -max_total_time=600
```
What has not been verified: files from Affinity builds or platforms outside the public set, files
written by other applications, rotated or skewed images against Affinity's render, mask polarity
against a render with and without the mask, and anything listed as a warning above. The `synth`
builders write only what this reader accepts; Affinity has never opened their output, so they are
test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
XMP metadata. This reader never imports those fields.

5
crates/affinity/fuzz/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock

View File

@ -0,0 +1,41 @@
[package]
name = "photocraft-affinity-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
license = "MIT OR Apache-2.0"
[package.metadata]
cargo-fuzz = true
[workspace]
[features]
io = ["dep:photocraft-io"]
[dependencies]
libfuzzer-sys = "0.4"
photocraft-affinity = { path = "..", features = ["synth"] }
photocraft-io = { path = "../../io", optional = true }
[[bin]]
name = "preview"
path = "fuzz_targets/preview.rs"
test = false
doc = false
bench = false
[[bin]]
name = "import_preview"
path = "fuzz_targets/import_preview.rs"
required-features = ["io"]
test = false
doc = false
bench = false
[[bin]]
name = "stream"
path = "fuzz_targets/stream.rs"
test = false
doc = false
bench = false

View File

@ -0,0 +1,10 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// The complete import: native document mapping, or the preview fallback.
if photocraft_affinity::is_affinity(data) {
let _ = photocraft_io::import("fuzz.af", data);
}
});

View File

@ -0,0 +1,10 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The whole native reader (archive, object stream, model) and the preview, on arbitrary bytes.
fuzz_target!(|data: &[u8]| {
let _ = photocraft_affinity::container::header(data);
let _ = photocraft_affinity::preview(data);
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});

View File

@ -0,0 +1,11 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so
// mutations reach the tagged grammar instead of the archive's checksum.
fuzz_target!(|stream: &[u8]| {
let _ = photocraft_affinity::stream::parse(stream);
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});

View File

@ -0,0 +1,105 @@
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
//! another type, so the document mapping treats odd data as absent rather than failing.
use crate::stream::{ObjId, Object, Stream, Tag, Value};
impl Stream {
pub fn object(&self, id: ObjId) -> Option<&Object> {
self.objects.get(id)
}
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
self.object(id)?.get(Tag::of(tag))
}
pub fn class(&self, id: ObjId) -> Option<Tag> {
self.object(id).map(|o| o.class)
}
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
self.class(id) == Some(Tag::of(class))
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
Value::Obj(o) => *o,
_ => None,
}
}
/// An array of objects (nulls skipped).
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
match self.field(id, tag) {
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
_ => Vec::new(),
}
}
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match self.field(id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
match self.field(id, tag)? {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
match self.field(id, tag)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Str(s) => Some(s),
_ => None,
}
}
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
match self.field(id, tag)? {
Value::Enum { id, version } => Some((*id, *version)),
_ => None,
}
}
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
match self.field(id, tag)? {
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
let mut a = [0.0; N];
a.copy_from_slice(v);
Some(a)
}
_ => None,
}
}
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
match self.field(id, tag)? {
Value::Bytes(b) | Value::Blob(b) => Some(b),
_ => None,
}
}
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Entry(s) => Some(s),
_ => None,
}
}
/// Untagged fields of a positional object, in order.
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}

View File

@ -0,0 +1,371 @@
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
use std::collections::{HashMap, HashSet};
use std::io::Read;
use crate::Error;
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// Decompression budget for one import. Every entry declares its size, which is checked against
/// these limits before anything is allocated, and the decoded length must match it exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Largest single entry (`doc.dat` or one tile).
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
}
}
/// The container header, all integers little-endian.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u16,
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
pub flags: u16,
pub(crate) fat_offset: u64,
pub(crate) thumbnail_offset: u64,
/// Save time in Unix seconds, as written by the application.
pub saved: u32,
}
/// One FAT record, as of the newest table that mentions its id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub id: u32,
pub name: String,
offset: u64,
size: u64,
compressed: u64,
crc: u32,
compression: u8,
}
impl Entry {
/// Decompressed size declared by the table.
pub fn size(&self) -> u64 {
self.size
}
}
#[derive(Debug)]
pub struct Archive<'a> {
bytes: &'a [u8],
pub header: Header,
/// Head revision of every named, non-deleted entry.
entries: HashMap<String, Entry>,
/// Number of tables (saves) in the chain.
pub revisions: usize,
extracted: usize,
limits: Limits,
}
pub(crate) struct Cursor<'a> {
bytes: &'a [u8],
pub(crate) pos: usize,
}
impl<'a> Cursor<'a> {
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
Self { bytes, pos }
}
pub(crate) fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
self.pos = end;
Ok(s)
}
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
let s = self.take(N)?;
let mut a = [0u8; N];
a.copy_from_slice(s);
Ok(a)
}
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
}
/// True when `bytes` start with the Affinity container signature (documents and libraries).
pub fn is_affinity(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
/// Check the signature, version and document kind.
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
if !is_affinity(bytes) {
return Err(Error::Malformed("missing container signature"));
}
let mut c = Cursor::new(bytes, 4);
let version = c.u16()?;
let flags = c.u16()?;
if !VERSIONS.contains(&version) {
return Err(Error::Unsupported("unknown container version"));
}
if flags & 3 != 0 {
return Err(Error::Unsupported("container variant"));
}
if c.take(4)? != DOCUMENT_KIND {
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
}
if c.take(4)? != b"#Inf" {
return Err(Error::Malformed("missing information header"));
}
let fat_offset = c.u64()?;
let thumbnail_offset = c.u64()?;
c.take(16)?;
let saved = u32::try_from(c.u64()?).unwrap_or(0);
c.take(8)?;
if c.take(4)? != b"Prot" {
return Err(Error::Malformed("missing protocol header"));
}
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
}
impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
while next != 0 {
if !seen.insert(next) {
return Err(Error::Malformed("allocation tables form a cycle"));
}
tables += 1;
if tables > MAX_TABLES {
return Err(Error::Limit("too many saved revisions"));
}
let mut c = Cursor::new(bytes, offset(next)?);
let tag = c.array::<4>()?;
let level = match &tag {
b"#FAT" => 1,
b"#FT2" => 2,
b"#FT3" => 3,
b"#FT4" => 4,
_ => return Err(Error::Malformed("missing allocation table")),
};
next = c.u64()?;
let date = c.u64()?;
c.take(24)?;
let files = c.u32()?;
c.take(8)?;
let dirs = c.u16()?;
c.take(1)?;
for _ in 0..files {
let id = c.u32()?;
let flag = c.u8()?;
let mut record = None;
match flag {
0 | 1 => {
let offset = c.u64()?;
let size = c.u64()?;
let compressed = c.u64()?;
let crc = c.u32()?;
let stored = c.u8()?;
if level >= 2 {
c.take(4)?;
}
if level >= 4 {
c.take(4)?;
}
// Old tables number the methods; newer ones store the method byte itself.
let compression = if level <= 2 {
match stored {
1 => 0x01,
2 => 0x41,
3 => 0x81,
4 => 0xC1,
_ => 0,
}
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
latest.insert(id, (date, tables, record));
}
}
for _ in 0..dirs {
let len = usize::from(c.u16()?);
c.take(10)?;
c.take(len)?;
}
}
let mut entries = HashMap::new();
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.get(name)
}
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
/// Bytes extracted so far, counted against [`Limits::max_total`].
pub fn extracted(&self) -> usize {
self.extracted
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
if size > self.limits.max_entry {
return Err(Error::Limit("archive entry too large"));
}
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
}
let method = e.compression & 3;
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
let data = c.take(offset(stored_len)?)?;
let mut out = match method {
1 => inflate(data, size)?,
2 => unzstd(data, size)?,
_ => data.to_vec(),
};
if out.len() != size {
return Err(Error::Malformed("archive entry size mismatch"));
}
unpredict(&mut out, e.compression)?;
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
/// without unbounded allocation.
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(size.min(64 << 20));
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
Ok(out)
}
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
let mut src = data;
let mut out = Vec::new();
// Entries may hold several concatenated frames.
while !src.is_empty() {
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
let frame = read_bounded(decoder, left)?;
if frame.is_empty() {
return Err(Error::Malformed("corrupt compressed entry"));
}
out.extend_from_slice(&frame);
if out.len() > size {
return Err(Error::Malformed("archive entry size mismatch"));
}
}
Ok(out)
}
/// Undo the byte predictor selected by the high bits of the compression byte.
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
match (compression & 0xC0, compression & 0x20 != 0) {
(0x00, _) => {}
(0x40, _) => byte_delta(b),
(0x80, false) => {
let mut prev = 0u16;
for pair in b.as_chunks_mut::<2>().0.iter_mut() {
let v = u16::from_le_bytes(*pair).wrapping_add(prev);
*pair = v.to_le_bytes();
prev = v;
}
}
(0x80, true) => {
byte_delta(b);
// A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes.
if b.len() == 0x10000 {
let planes = b.to_vec();
let (hi, lo) = planes.split_at(0x8000);
for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
return Err(Error::Malformed("tile size"));
};
*out = [l[0], h[0], l[1], h[1]];
}
}
}
_ => return Err(Error::Unsupported("archive entry predictor")),
}
Ok(())
}
fn byte_delta(b: &mut [u8]) {
let mut prev = 0u8;
for v in b {
*v = v.wrapping_add(prev);
prev = *v;
}
}

View File

@ -0,0 +1,175 @@
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
//! node space.
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
let path = curves(r, crvs)?;
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
None => {
let master = s.obj(id, b"ShpM")?;
(s.obj(master, b"Shpe")?, master)
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
}
}
}
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
let s = r.s;
let data = s.obj(crvs, b"Data")?;
let mut fields = s.positional(data);
fields.next()?;
let count = match fields.next()? {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
let closed = match fields.next()? {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
if seen > MAX_NODES {
r.warn("curves with more than four million nodes");
return None;
}
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
}
}
if corners.is_some() && count > 1 {
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
}
Some(path)
}
struct Anchor {
p: Point,
inh: Option<Point>,
out: Option<Point>,
/// Index of the anchor's record (live corners refer to records).
record: usize,
}
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
}
1 => {
// A leading out-handle without an anchor is edit state only.
if let Some(a) = out.last_mut() {
a.out = Some(p);
}
}
2 => pending_in = Some(p),
_ => return None,
}
}
Some(out)
}
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
// A closed subpath repeats its first anchor; its radius may be on either copy.
if closed
&& pts.len() > 1
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
&& let Some((_, r)) = pts.pop()
&& let Some(f) = pts.first_mut()
{
f.1 = f.1.max(r);
}
return Some(crate::shapes::fillet(&pts, closed));
}
}
let segments = anchors
.windows(2)
.filter_map(|w| {
let [a, b] = w else { return None };
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
})
.collect();
Some(SubPath { start: first.p, segments, closed })
}
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
Some(
idx.iter()
.zip(rad)
.filter_map(|(i, r)| match (i, r) {
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
_ => None,
})
.collect(),
)
}

View File

@ -0,0 +1,51 @@
//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from
//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the
//! subset of the document model PhotoCraft imports.
//!
//! Layouts follow the MIT-licensed afread reference and were checked against public documents
//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance
//! and the exact supported subset. Nothing here writes Affinity files.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::fmt;
mod access;
pub mod container;
mod geometry;
pub mod model;
pub mod paint;
pub mod preview;
mod raster;
mod shapes;
pub mod stream;
#[cfg(any(test, feature = "synth"))]
pub mod synth;
mod text;
pub use container::{Archive, Limits, MAGIC, is_affinity};
pub use model::{Document, read};
pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview};
/// Why a file could not be read. Messages are short reasons; callers add advice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
Malformed(&'static str),
Unsupported(&'static str),
Limit(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"),
Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"),
Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"),
}
}
}
impl std::error::Error for Error {}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,587 @@
//! The document model PhotoCraft imports: spreads, artboards, layers, groups, curves, shapes,
//! text and images, with transforms composed into document space. Anything else is reported
//! by [`Document::warnings`] instead of being dropped silently.
use std::collections::{BTreeMap, HashSet};
use crate::paint::{self, Paint, Stroke};
use crate::stream::{ObjId, Stream, Tag};
use crate::{Archive, Error, Limits, stream};
/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine(pub [f64; 6]);
impl Affine {
pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
/// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f).
pub(crate) fn from_xfrm(m: [f64; 6]) -> Self {
Self([m[0], m[3], m[1], m[4], m[2], m[5]])
}
pub fn then(self, outer: Self) -> Self {
let [a, b, c, d, e, f] = self.0;
let [oa, ob, oc, od, oe, of] = outer.0;
Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of])
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f] = self.0;
Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }
}
/// Geometric mean of the axis scales: how much a stroke width or font size grows.
pub fn scale(&self) -> f64 {
let [a, b, c, d, ..] = self.0;
(a * d - b * c).abs().sqrt()
}
pub fn is_finite(&self) -> bool {
self.0.iter().all(|v| v.is_finite())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
/// One cubic Bézier subpath, in document pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct SubPath {
pub start: Point,
/// (control 1, control 2, end) per segment.
pub segments: Vec<[Point; 3]>,
pub closed: bool,
}
/// An outline in document pixels.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Path {
pub subpaths: Vec<SubPath>,
}
impl Path {
pub fn transformed(&self, m: Affine) -> Self {
Self {
subpaths: self
.subpaths
.iter()
.map(|s| SubPath { start: m.apply(s.start), segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(), closed: s.closed })
.collect(),
}
}
}
/// Affinity's blend modes, numbered by the `Blnd` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Blend {
#[default]
Normal,
PassThrough,
Darken,
DarkerColor,
Multiply,
ColorBurn,
Lighten,
LighterColor,
Screen,
ColorDodge,
Add,
Overlay,
SoftLight,
HardLight,
VividLight,
PinLight,
LinearLight,
HardMix,
Difference,
Exclusion,
Subtract,
Hue,
Saturation,
Luminosity,
Color,
Average,
Negation,
Reflect,
Glow,
Erase,
}
impl Blend {
/// The enum (id, version) pairs observed in a document whose layers are named after their mode.
fn from_enum(id: u16, version: u16) -> Option<Self> {
use Blend::*;
Some(match (id, version) {
(0, _) => Normal,
(1, 0) => Darken,
(2, 1) => DarkerColor,
(2, 0) => Multiply,
(3, 0) => ColorBurn,
(4, 0) => Lighten,
(6, 1) => LighterColor,
(5, 0) => Screen,
(6, 0) => ColorDodge,
(7, 0) => Add,
(8, 0) => Overlay,
(9, 0) => SoftLight,
(10, 0) => HardLight,
(11, 0) => VividLight,
(12, 0) => PinLight,
(15, 1) => LinearLight,
(13, 0) => HardMix,
(14, 0) => Difference,
(15, 0) => Exclusion,
(16, 0) => Subtract,
(17, 0) => Hue,
(18, 0) => Saturation,
(19, 0) => Luminosity,
(20, 0) => Color,
(21, 0) => Average,
(22, 0) => Negation,
(23, 0) => Reflect,
(24, 0) => Glow,
(25, 0) => Erase,
_ => return None,
})
}
}
/// A run of text with one set of character attributes.
#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
pub text: String,
/// PostScript name and family of the chosen font.
pub postscript: String,
pub family: String,
/// CSS weight (400 regular, 700 bold).
pub weight: i64,
pub italic: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>,
pub fill: Paint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub runs: Vec<TextRun>,
pub align: Align,
/// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
/// line according to `align`). Frame text: the frame box in node space.
pub anchor: Point,
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Pixels,
/// Pixel space (0..w, 0..h) to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
/// Layer container (`Scop`).
Layer,
Group,
/// Artboard: a rectangle that clips its children; `background` paints behind them.
Artboard {
rect: Rect,
background: Vec<Paint>,
},
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
Shape {
path: Path,
fills: Vec<Paint>,
strokes: Vec<Stroke>,
even_odd: bool,
},
Text(Text),
Image(Image),
/// Something this reader does not import; its children are still imported.
Unsupported,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
pub name: String,
pub visible: bool,
pub locked: bool,
pub opacity: f64,
pub blend: Blend,
pub kind: Kind,
/// Vector mask in document pixels: the node shows only inside it (nonzero fill).
pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Bottom to top.
pub children: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spread {
/// Spread bounds in document pixels (the canvas, or the union of the artboards).
pub bounds: Rect,
/// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
pub pages: Vec<Rect>,
/// Transparent background; otherwise the page is white.
pub transparent: bool,
/// Bottom to top.
pub nodes: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>,
}
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
fn document(mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
let saved_by = s.obj(root, b"NVer").map(|v| {
let product = s.str(v, b"Prod").unwrap_or("Affinity");
match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
(Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
_ => product.to_string(),
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") {
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
spreads.push(Spread { bounds, pages, transparent, nodes });
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
if !s.objs(doc, b"MpCh").is_empty() {
self.warn("master pages (their content is not placed on the pages that use them)");
}
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
}
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
if s.bool(c, b"Visi") == Some(false) {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
match crate::raster::mask(self, c, local.then(world))? {
Some(m) if mask.is_none() => mask = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
continue;
}
if let Some(n) = self.node(c, world, depth + 1)? {
out.push(n);
}
}
Ok((out, mask))
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
self.nodes += 1;
if self.nodes > MAX_NODES {
return Err(Error::Limit("too many layers"));
}
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
return Ok(None);
}
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
let blend = match s.enumeration(id, b"Blnd") {
None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
};
let kind = if !world.is_finite() {
self.warn("a layer with an invalid transform");
Kind::Unsupported
} else {
self.kind(id, class, world, parent)?
};
// A compound's children are its operands, already merged into its outline.
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
let s = self.s;
Ok(match &class.0.to_be_bytes() {
b"Scop" => Kind::Layer,
b"Grup" => Kind::Group,
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
return Ok(self.artboard(id, world));
}
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
self.warn(format!("a {} that could not be read", describe(class)));
return Ok(Kind::Unsupported);
};
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
self.warn("text whose story could not be read (master-page text, or an unknown layout)");
Kind::Unsupported
}
},
b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
b"EmbN" => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
},
b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space.
let b = s.obj(id, b"Bitm");
let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
match (size(b"BmpW"), size(b"BmpH")) {
(Some(w), Some(h)) => {
let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
let fills = paint::fills(self, id, parent, b"BFFl");
Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
}
_ => {
self.warn("fill layers without an area");
Kind::Unsupported
}
}
}
_ => {
self.warn(format!("{} layers", describe(class)));
Kind::Unsupported
}
})
}
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
for a in attached {
let class = s.class(a).unwrap_or(Tag(0));
if s.bool(a, b"Visi") == Some(false) {
continue;
}
if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
}
Ok((mask, pixels))
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
let corners = [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
let [a, b2, c, d, ..] = world.0;
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
self.warn("a rotated or flipped artboard (imported at its bounding box)");
}
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
let rect = Rect {
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
};
let background = paint::fills(self, id, world, b"BFFl");
Kind::Artboard { rect, background }
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
}
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
}
}
pub(crate) fn union(a: Rect, b: Rect) -> Rect {
Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
}
fn is_container(class: Tag) -> bool {
class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
}
pub(crate) fn rect(v: [f64; 4]) -> Rect {
Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
}
/// A readable name for a layer class in warnings.
pub(crate) fn describe(class: Tag) -> &'static str {
match &class.0.to_be_bytes() {
b"PCrv" => "curve",
b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
b"Comp" => "compound",
b"Rstr" => "pixel",
b"ImgN" => "image",
b"EmbN" => "embedded document",
b"MPIN" => "master page instance",
b"FRst" => "fill",
b"MRst" => "mask",
b"Slic" | b"SlcP" => "slice",
b"3DLA" => "3D",
[_, _, b'R', b'A'] => "adjustment",
_ => "unknown",
}
}

View File

@ -0,0 +1,329 @@
//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
use crate::model::{Affine, Reader};
use crate::stream::{ObjId, Value};
/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Rgb {
r: f64,
g: f64,
b: f64,
a: f64,
},
Cmyk {
c: f64,
m: f64,
y: f64,
k: f64,
a: f64,
},
Gray {
v: f64,
a: f64,
},
/// CIE L*a*b* (D50).
Lab {
l: f64,
a: f64,
b: f64,
alpha: f64,
},
}
impl Color {
pub fn alpha(&self) -> f64 {
match self {
Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
Color::Lab { alpha, .. } => *alpha,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientKind {
Linear,
/// Elliptical and radial: distance from the centre in unit space.
Radial,
/// Angle around the centre.
Conical,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stop {
pub offset: f64,
pub color: Color,
/// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
pub bias: f64,
}
impl Stop {
/// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
/// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
pub fn half_point(&self) -> f64 {
let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
let h = 0.5f64.powf(1.0 / k);
if self.bias >= 0.5 { h } else { 1.0 - h }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<Stop>,
/// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
/// circle; conical starts along unit +x and turns towards unit +y.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Paint {
#[default]
None,
Solid(Color),
Gradient(Gradient),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Join {
Miter,
Round,
Bevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Center,
Inside,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub paint: Paint,
/// Width in document pixels.
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
pub align: Align,
/// Dash and gap lengths in document pixels, and the phase.
pub dash: Option<(Vec<f64>, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
}
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
_ => None,
}
}
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
let v = floats(s.field(id, b"_col")?)?;
v.iter().all(|f| f.is_finite()).then_some(v)
};
Some(match &class.0.to_be_bytes() {
b"RGBA" => {
let v = col()?;
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"HSLA" => {
let v = col()?;
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"CMYK" => {
let v = col()?;
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
}
b"GRAY" => {
let v = col()?;
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
}
b"LABA" => {
let b = s.bytes(id, b"_col")?;
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
r.warn("colours in an unknown model");
return None;
}
})
}
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(r + m, g + m, b + m)
}
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
/// space to document pixels.
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
let s = r.s;
let (fill, unit) = if s.is(id, b"FDsc") {
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
(s.obj(id, b"FDeF")?, unit)
} else {
(id, Affine::IDENTITY)
};
match &s.class(fill)?.0.to_be_bytes() {
b"FilN" => Some(Paint::None),
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
b"FilG" => {
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
Some(0) | None => GradientKind::Linear,
Some(1 | 2) => GradientKind::Radial,
Some(3) => GradientKind::Conical,
Some(_) => {
r.warn("gradients of an unknown kind (imported as linear)");
GradientKind::Linear
}
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
let color = color(r, *c)?;
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
stops.push(Stop { offset, color, bias });
}
if stops.is_empty() {
return Some(Paint::None);
}
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
}
b"FilB" => {
r.warn("bitmap and pattern fills (left empty)");
Some(Paint::None)
}
_ => {
r.warn("fills of an unknown kind (left empty)");
Some(Paint::None)
}
}
}
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
r.warn("several fills or strokes on one object (only the active one is imported)");
}
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
r.warn("fill opacity (imported at full fill opacity)");
}
if s.obj(id, b"Trns").is_some() {
r.warn("transparency gradients (imported without them)");
}
let (pen, style) = if s.field(id, b"LIFl").is_some() {
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
};
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
let stroke = match (pen, style) {
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
_ => None,
};
(fills, stroke.into_iter().collect())
}
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
let s = r.s;
let paint = descriptor(r, pen, local)?;
if paint == Paint::None {
return None;
}
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
// looks wrong.
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
_ => (Cap::Round, Join::Round),
};
let align = match s.int(ldsc, b"LDSa") {
Some(1) => Align::Inside,
Some(2) => Align::Outside,
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
v.truncate(v.len() - 2);
}
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
}
_ => None,
};
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
r.warn("brush and pressure strokes (imported as plain strokes)");
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}

View File

@ -0,0 +1,127 @@
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
//! document, at most 512 pixels on the long side in real files, never the document itself.
use crate::Error;
use crate::container::header;
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
#[derive(Debug)]
pub struct Preview<'a> {
pub png: &'a [u8],
pub width: u32,
pub height: u32,
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
}
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
}
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
}
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
/// pixels must still be validated by the consuming image decoder, with allocation limits.
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
let h = header(bytes)?;
if h.thumbnail_offset == 0 {
return Err(Error::Unsupported("no embedded preview"));
}
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
if offset < 72 {
return Err(Error::Malformed("thumbnail overlaps the file header"));
}
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
return Err(Error::Malformed("missing indexed thumbnail"));
}
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
return Err(Error::Unsupported("thumbnail record version or encoding"));
}
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
if size > MAX_PREVIEW_BYTES {
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
}
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
}
let png = bytes_at(record, 29, size)?;
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
return Err(Error::Malformed("thumbnail is not a PNG"));
}
let width = be32(png, 16)?;
let height = be32(png, 20)?;
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
return Err(Error::Limit("preview dimensions must be 1..4096"));
}
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
let mut pos = 8usize;
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
loop {
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
let chunk = bytes_at(png, pos, chunk_size)?;
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
return Err(Error::Malformed("PNG chunk checksum"));
}
let kind = bytes_at(chunk, 4, 4)?;
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err(Error::Malformed("PNG chunk type"));
}
match kind {
b"IHDR" if pos == 8 => {}
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
b"PLTE" => {
if palette || image_data {
return Err(Error::Malformed("PNG palette order"));
}
palette = true;
}
b"IDAT" => {
if image_data_ended {
return Err(Error::Malformed("non-contiguous PNG image data"));
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
_ => {}
}
if image_data && kind != b"IDAT" {
image_data_ended = true;
}
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
}
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
return Err(Error::Unsupported("animated PNG preview"));
}
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
if kind == b"IEND" {
if length != 0 || pos != png.len() {
return Err(Error::Malformed("PNG end or trailing data"));
}
break;
}
}
Ok(Preview { png, width, height })
}

View File

@ -0,0 +1,243 @@
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
//! 256 bytes × 256 rows per channel, each its own archive entry.
use std::collections::HashMap;
use crate::Error;
use crate::model::{Affine, Image, Pixels, Reader};
use crate::stream::{self, ObjId, Tag, Value};
/// Pixels decoded per layer (the content's bounding box, not the canvas).
const MAX_PIXELS: u64 = 64 << 20;
const TILE: usize = 256;
const TILE_BYTES: usize = TILE * TILE;
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else {
r.warn("pixel layers without pixels");
return Ok(None);
};
if !s.is(bitmap, b"DyBm") {
r.warn("pixel layers stored in an unknown way");
return Ok(None);
}
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
// A placed image: the original file, byte for byte.
let has_tiles = s.field(bitmap, b"Sta1").is_some();
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
}
}
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
let crop = s
.field(id, b"BitR")
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
.map(|v| {
(
v[0].clamp(0, i64::from(width)) as u32,
v[1].clamp(0, i64::from(height)) as u32,
v[2].clamp(0, i64::from(width)) as u32,
v[3].clamp(0, i64::from(height)) as u32,
)
})
.unwrap_or((0, 0, width, height));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
r.warn("edited placed images use their stored pixels");
}
decode(r, bitmap, crop, world)
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
decode(r, bitmap, (0, 0, width, height), world)
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
let [x0, y0, x1, y1] = bbox;
// The instance's origin is the centre of the embedded content (checked against the
// thumbnails of documents with embedded documents and SVG files).
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
decode(r, cache, (0, 0, width, height), place)
}
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
Some(4) => {
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
(5, 1)
}
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
}
};
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
}
let n = (w as usize) * (h as usize);
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
let bytes = r.archive.read(name)?;
let s = stream::parse(&bytes)?;
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
match root.get(Tag::of(b"Data")) {
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
_ => Err(Error::Malformed("embedded image without data")),
}
}
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
cache: &mut HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
cache.insert(name.clone(), data);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
r.warn("32-bit pixel layers");
None
}
5 => {
r.warn("pixel layers drawn from an embedded image (left empty)");
None
}
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
for y in oy.max(py0)..(oy + TILE).min(py1) {
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
*b = at(x - ox, y - oy);
}
}
}
}

View File

@ -0,0 +1,259 @@
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Handle length of a quarter circle of radius 1.
const KAPPA: f64 = 0.552_284_749_830_793_4;
/// Vertices of a polygon or star.
const MAX_SIDES: i64 = 10_000;
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
let t = Tag::of(tag);
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
}
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match last(r, id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
Value::UInt(u) => Some(*u as f64),
Value::Int(i) => Some(*i as f64),
_ => None,
}
}
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
let n = num(r, id, tag).unwrap_or(default).round();
n.clamp(3.0, MAX_SIDES as f64) as usize
}
fn pt(x: f64, y: f64) -> Point {
Point { x, y }
}
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
let mut it = pts.into_iter();
let start = it.next()?;
let segments = it.map(|p| [p, p, p]).collect();
Some(SubPath { start, segments, closed: true })
}
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
let [x0, y0, x1, y1] = b;
let (w, h) = (x1 - x0, y1 - y0);
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
b"ShPy" => {
let n = sides(r, shape, b"Side", 5.0);
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
let v = |i: usize| on_ellipse(a(i), 1.0);
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
let segments = (0..n)
.map(|i| {
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
})
.collect();
SubPath { start: v(0), segments, closed: true }
} else {
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
}
}
b"ShSt" => {
let n = sides(r, shape, b"Pnts", 5.0);
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("stars with rounded or curved points (imported with sharp points)");
}
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
}
b"ShSS" => {
let n = sides(r, shape, b"Side", 5.0);
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
let step = PI / n as f64;
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
polygon((0..n).flat_map(|k| {
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
let (c, s) = (phi.cos(), phi.sin());
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
}))?
}
b"ShPi" => {
let start = num(r, shape, b"AngS").unwrap_or(0.0);
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
while end < start {
end += TAU;
}
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
}
b"ShTz" => {
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
}
_ => {
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
ellipse(cx, cy, rx, ry)
}
};
Some(Path { subpaths: vec![sub] })
}
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
}
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
SubPath {
start: pt(cx + rx, cy),
segments: vec![
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
],
closed: true,
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
}
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
/// as cubic segments of at most a quarter turn.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
for i in 0..n {
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
}
}
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a0, a1, &mut segments);
if inner > 0.0 {
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
segments.push([p, p, p]);
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
} else {
let c = pt(cx, cy);
segments.push([c, c, c]);
}
SubPath { start, segments, closed: true }
}
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
let n = pts.len();
let get = |i: isize| -> Option<Point> {
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
};
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
for (i, (p, radius)) in pts.iter().enumerate() {
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
let (Some(a), Some(b)) = (prev, next) else {
corners.push((*p, *p, *p, *p));
continue;
};
let (u1, l1) = unit(*p, a);
let (u2, l2) = unit(*p, b);
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
let phi = cos.acos();
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
corners.push((*p, *p, *p, *p));
continue;
}
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
let r = t * (phi / 2.0).tan();
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
}
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
let mut segments = Vec::with_capacity(2 * n);
let mut cur = first.0;
let start = cur;
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
if i > 0 {
segments.push([cur, *s, *s]);
}
if s != e {
segments.push([*c1, *c2, *e]);
}
cur = *e;
}
if closed && cur != start {
segments.push([cur, start, start]);
}
SubPath { start, segments, closed }
}
fn unit(from: Point, to: Point) -> (Point, f64) {
let (dx, dy) = (to.x - from.x, to.y - from.y);
let l = (dx * dx + dy * dy).sqrt();
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
}

View File

@ -0,0 +1,402 @@
//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
//! document mapping can name what it could not import.
use crate::Error;
use crate::container::Cursor;
/// A four-character tag in its readable order (`Desc`, `Chld`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tag(pub u32);
impl Tag {
pub const fn of(s: &[u8; 4]) -> Self {
Self(u32::from_be_bytes(*s))
}
}
impl std::fmt::Debug for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
/// Index of an object in [`Stream::objects`].
pub type ObjId = usize;
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Int(i64),
UInt(u64),
Float(f64),
Bool(bool),
Enum {
id: u16,
version: u16,
},
Str(String),
/// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
Ints(Vec<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Root,
/// Defined once and referenced by id elsewhere.
Shared,
Inline,
/// Untagged fields identified by position (curve data).
Positional,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Object {
pub class: Tag,
pub kind: Kind,
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
}
impl Object {
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
}
}
#[derive(Debug)]
pub struct Stream {
pub root: ObjId,
pub objects: Vec<Object>,
}
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
pub const MAX_DEPTH: usize = 384;
const MAX_OBJECTS: usize = 8_000_000;
/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes
/// in memory however few it took in the file (a bool array packs eight in a byte), so the size
/// of the input alone doesn't bound what parsing allocates: a small compressed entry could
/// otherwise ask for tens of gigabytes. Real documents stay far below.
pub const MAX_VALUES: usize = 1 << 24;
struct Parser<'a> {
c: Cursor<'a>,
objects: Vec<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
depth: usize,
/// Values decoded so far, against [`MAX_VALUES`].
values: usize,
}
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
let mut c = Cursor::new(bytes, 0);
if c.take(4)? != b"\x00\xffKS" {
return Err(Error::Malformed("document stream signature"));
}
let version = c.u16()?;
if version > 2 {
return Err(Error::Unsupported("document stream version"));
}
let class = Tag(c.u32()?);
c.u16()?;
if version == 2 {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
}
Ok(Stream { root, objects: p.objects })
}
impl Parser<'_> {
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
if self.objects.len() >= MAX_OBJECTS {
return Err(Error::Limit("too many objects"));
}
self.objects.push(o);
Ok(self.objects.len() - 1)
}
/// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them.
fn spend(&mut self, n: usize) -> Result<(), Error> {
self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?;
Ok(())
}
/// An empty array with room for `n` values, paid for first; an allocation that still fails
/// is an error, not an abort.
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>, Error> {
self.spend(n)?;
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
Ok(v)
}
/// Element count of an array: each element takes at least `min` bytes, so a count the
/// remaining input cannot hold is rejected before anything is allocated.
fn count(&mut self, min: usize) -> Result<usize, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
Ok(n)
}
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::Limit("objects nested too deeply"));
}
let mut out = Vec::new();
loop {
let t = self.c.u8()?;
let code = t & 0x7F;
if code == 0 {
break;
}
self.spend(1)?;
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
out.push((tag, v));
}
self.depth -= 1;
Ok(out)
}
fn string(&mut self) -> Result<String, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
}
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
}
Ok(Value::Floats(v))
}
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
let c = &mut self.c;
Ok(match code {
0x01 => Value::UInt(c.u8()?.into()),
0x02 => Value::UInt(c.u16()?.into()),
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
0x04 => Value::UInt(c.u64()?),
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
0x15..=0x19 => {
let n = usize::from(code - 0x13);
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i32::from_le_bytes(c.array()?).into());
}
Value::Ints(v)
}
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
0x29 => Value::Bool(c.u8()? != 0),
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
0x2b | 0x2e => Value::Str(self.string()?),
0x2c => {
let n = usize::from(c.u16()?);
Value::Bytes(c.take(n)?.to_vec())
}
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
0x2d => {
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
Value::Blob(c.take(n)?.to_vec())
}
0x33 => {
c.u32()?;
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
}
0x75 => {
c.u16()?;
let n = usize::from(c.u8()?);
if n > 8 {
return Err(Error::Malformed("flags wider than 64 bits"));
}
let mut b = [0u8; 8];
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
Value::Flags(u64::from_le_bytes(b))
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
0x31 => self.shared()?,
0x32 => self.inline(None)?,
_ => return Err(Error::Unsupported("unknown field type in document stream")),
})
}
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
let min = match code {
0x01 | 0x05 | 0x29 => 1,
0x02 | 0x06 => 2,
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
0x04 | 0x08 | 0x0a => 8,
0x15..=0x19 => 4 * usize::from(code - 0x13),
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
0x24..=0x28 => 8 * usize::from(code - 0x22),
0x35..=0x74 => usize::from(code - 0x34),
_ => 1,
};
match code {
0x29 => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let bits = self.c.take(n.div_ceil(8))?;
let mut v = self.vec(n)?;
v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))));
Ok(Value::Array(v))
}
0x2a => {
let n = self.count(2)?;
let version = self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Enum { id: self.c.u16()?, version });
}
Ok(Value::Array(v))
}
0x2b | 0x2e => {
self.c.u32()?;
let n = self.count(4)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Str(self.string()?));
}
Ok(Value::Array(v))
}
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
}
0x32 => {
let n = self.count(1)?;
let class = Tag(self.c.u32()?);
self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.inline(Some(class))?);
}
Ok(Value::Array(v))
}
0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
_ => {
let n = self.count(min)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.scalar(code, true, class)?);
}
Ok(Value::Array(v))
}
}
}
/// Inline object; `class` is given once for a whole array of them.
fn inline(&mut self, class: Option<Tag>) -> Result<Value, Error> {
match self.c.u8()? {
0 => return Ok(Value::Obj(None)),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
let class = match class {
Some(c) => c,
None => {
let c = Tag(self.c.u32()?);
self.c.u16()?;
c
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
Ok(Value::Obj(Some(id)))
}
fn shared(&mut self) -> Result<Value, Error> {
let flag = self.c.u8()?;
if flag == 0 {
return Ok(Value::Obj(None));
}
let key = self.c.u32()?;
match flag {
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
if self.shared.contains_key(&key) {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
loop {
match self.c.u8()? {
0 => {
let tag = Tag(self.c.u32()?);
self.c.u16()?;
chain.push(tag);
fields.extend(self.fields(true, tag)?);
}
1 => {
chain.push(Tag(self.c.u32()?));
break;
}
2 => break,
_ => return Err(Error::Malformed("object class chain")),
}
if chain.len() > 64 {
return Err(Error::Limit("object class chain too long"));
}
}
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object { class, kind: Kind::Shared, chain, fields };
}
Ok(Value::Obj(Some(id)))
}
}

View File

@ -0,0 +1,259 @@
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
//!
//! These builders write the layout the reader accepts, so tests can describe documents in a few
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::stream::Tag;
/// A field value to encode.
#[derive(Debug, Clone)]
pub enum F {
U8(u8),
U32(u32),
I32(i32),
F32(f32),
F64(f64),
Bool(bool),
Enum(u16, u16),
Str(String),
/// f64 vector of 2..6 values (points, rectangles, transforms).
F64s(Vec<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, t: Tag) {
out.extend(t.0.to_le_bytes());
}
fn code(f: &F) -> u8 {
match f {
F::U8(_) => 0x01,
F::U32(_) => 0x03,
F::I32(_) => 0x07,
F::F32(_) => 0x09,
F::F64(_) => 0x0a,
F::Bool(_) => 0x29,
F::Enum(..) => 0x2a,
F::Str(_) => 0x2b,
F::F64s(v) => 0x22 + v.len() as u8,
F::Struct(b) => 0x34 + b.len() as u8,
F::Records(..) => 0x80 | 0x2c,
F::U8s(_) => 0x80 | 0x01,
F::Entry(_) => 0x33,
F::Def(..) | F::Ref(_) => 0x31,
F::Null => 0x31,
F::Obj(..) => 0x32,
F::Pos(_) => 0x30,
F::Shared(_) => 0x80 | 0x31,
F::Objs(..) => 0x80 | 0x32,
F::Raw(_) => 0,
}
}
fn payload(out: &mut Vec<u8>, f: &F) {
match f {
F::U8(v) => out.push(*v),
F::U32(v) => out.extend(v.to_le_bytes()),
F::I32(v) => out.extend(v.to_le_bytes()),
F::F32(v) => out.extend(v.to_le_bytes()),
F::F64(v) => out.extend(v.to_le_bytes()),
F::Bool(v) => out.push(u8::from(*v)),
F::Enum(id, ver) => {
out.extend(id.to_le_bytes());
out.extend(ver.to_le_bytes());
}
F::Str(s) => {
out.extend((s.len() as u32).to_le_bytes());
out.extend(s.as_bytes());
}
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
F::Struct(b) => out.extend(b),
F::Records(size, recs) => {
out.extend((recs.len() as u32).to_le_bytes());
out.extend(size.to_le_bytes());
recs.iter().for_each(|r| out.extend(r));
}
F::U8s(v) => {
out.extend((v.len() as u32).to_le_bytes());
out.extend(v);
}
F::Entry(name) => {
put_tag(out, tag(b"Data"));
out.extend((name.len() as u32).to_le_bytes());
out.extend(name.as_bytes());
}
F::Def(id, chain, fields) => {
out.push(1);
out.extend(id.to_le_bytes());
// Class chain: each level but the last carries no fields here; the last ends the chain.
let (last, rest) = chain.split_last().expect("class chain");
for t in rest {
out.push(0);
put_tag(out, *t);
out.extend(0u16.to_le_bytes());
out.push(0);
}
out.push(1);
put_tag(out, *last);
fields_into(out, fields, true);
}
F::Ref(id) => {
out.push(2);
out.extend(id.to_le_bytes());
}
F::Null => out.push(0),
F::Obj(class, fields) => {
out.push(1);
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
fields_into(out, fields, true);
}
F::Pos(items) => {
for f in items {
out.push(code(f));
payload(out, f);
}
out.push(0);
}
F::Shared(items) => {
out.extend((items.len() as u32).to_le_bytes());
items.iter().for_each(|f| payload(out, f));
}
F::Objs(class, items) => {
out.extend((items.len() as u32).to_le_bytes());
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
for fields in items {
out.push(1);
fields_into(out, fields, true);
}
}
F::Raw(b) => out.extend(b),
}
}
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
for (t, f) in fields {
if let F::Raw(b) = f {
out.extend(b);
continue;
}
out.push(code(f));
if tagged {
put_tag(out, *t);
}
payload(out, f);
}
out.push(0);
}
/// A `doc.dat` stream with root class `Pers`.
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
let mut out = b"\x00\xffKS".to_vec();
out.extend(2u16.to_le_bytes());
put_tag(&mut out, tag(b"Pers"));
out.extend(0u16.to_le_bytes());
out.extend(30u32.to_le_bytes());
fields_into(&mut out, root, true);
out
}
/// How an entry is stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Stored,
Zlib,
Zstd,
}
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
let mut out = vec![0u8; 72];
out[..4].copy_from_slice(crate::container::MAGIC);
out[4..6].copy_from_slice(&12u16.to_le_bytes());
out[6..8].copy_from_slice(&4u16.to_le_bytes());
out[8..12].copy_from_slice(b"nsrP");
out[12..16].copy_from_slice(b"#Inf");
out[64..68].copy_from_slice(b"Prot");
let mut records = Vec::new();
for (i, (name, data, method)) in entries.iter().enumerate() {
let packed = match method {
Method::Stored => data.to_vec(),
Method::Zlib => {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(data).unwrap();
e.finish().unwrap()
}
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
};
let offset = out.len() as u64;
out.extend(b"#Fil");
out.extend(&packed);
let mut r = Vec::new();
r.extend((i as u32 + 1).to_le_bytes());
r.push(0);
r.extend(offset.to_le_bytes());
r.extend((data.len() as u64).to_le_bytes());
r.extend((packed.len() as u64).to_le_bytes());
r.extend(crc32fast::hash(data).to_le_bytes());
r.push(match method {
Method::Stored => 0,
Method::Zlib => 1,
Method::Zstd => 2,
});
r.extend(0u32.to_le_bytes());
r.extend(0u32.to_le_bytes());
r.extend((name.len() as u16).to_le_bytes());
r.extend(name.as_bytes());
records.push(r);
}
let fat = out.len() as u64;
out.extend(b"#FT4");
out.extend(0u64.to_le_bytes());
out.extend(1_700_000_000u64.to_le_bytes());
out.extend([0u8; 24]);
out.extend((records.len() as u32).to_le_bytes());
out.extend([0u8; 8]);
out.extend(0u16.to_le_bytes());
out.push(0);
records.iter().for_each(|r| out.extend(r));
let thumb = out.len() as u64;
out[16..24].copy_from_slice(&fat.to_le_bytes());
if let Some(png) = thumbnail_png {
out[24..32].copy_from_slice(&thumb.to_le_bytes());
out.extend(b"\xff\xff\xff\xffThmb");
out.extend(1u32.to_le_bytes());
out.extend((png.len() as u32 + 13).to_le_bytes());
out.extend(29u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend((png.len() as u32).to_le_bytes());
out.push(1);
out.extend(png);
}
out
}

View File

@ -0,0 +1,351 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
use crate::*;
use proptest::prelude::*;
fn fixture() -> Vec<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(13u32.to_be_bytes());
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(1u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
png.extend([0; 4]);
png.extend(b"IDAT");
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
png.extend([0; 4]);
png.extend(b"IEND");
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
let mut b = vec![0; 72];
b[..4].copy_from_slice(MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn indexed_preview_and_header() {
let b = fixture();
assert_eq!(header(&b).unwrap().version, 12);
let p = preview(&b).unwrap();
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.png, &b[101..]);
}
#[test]
fn every_truncation_is_rejected() {
let b = fixture();
for end in 0..b.len() {
assert!(preview(&b[..end]).is_err(), "{end}");
}
}
#[test]
fn rejects_assets_versions_offsets_lengths_and_bombs() {
let b = fixture();
for (offset, value) in [
(4, 13u64.to_le_bytes().to_vec()),
(8, b"urBR".to_vec()),
(24, u64::MAX.to_le_bytes().to_vec()),
(24, 1u64.to_le_bytes().to_vec()),
(24, 0u64.to_le_bytes().to_vec()),
(84, u32::MAX.to_le_bytes().to_vec()),
(88, 30u32.to_le_bytes().to_vec()),
(96, u32::MAX.to_le_bytes().to_vec()),
(117, 5000u32.to_be_bytes().to_vec()),
] {
let mut broken = b.clone();
broken[offset..offset + value.len()].copy_from_slice(&value);
assert!(preview(&broken).is_err(), "offset {offset}");
}
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
for version in 8..12 {
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
assert!(preview(&legacy).is_ok());
}
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
let mut variant = b.clone();
variant[6..8].copy_from_slice(&flags.to_le_bytes());
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
}
let mut old = b.clone();
old[4..6].copy_from_slice(&7u16.to_le_bytes());
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
}
#[test]
fn an_unindexed_png_or_resource_is_never_used() {
let mut b = fixture();
b[24..32].copy_from_slice(&0u64.to_le_bytes());
assert!(preview(&b).is_err());
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[76..80].copy_from_slice(b"Meta");
assert!(preview(&b).is_err());
}
#[test]
fn every_chunk_checksum_including_the_end_is_checked() {
let b = fixture();
for at in [130, b.len() - 1] {
let mut damaged = b.clone();
damaged[at] ^= 1;
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
let mut ancillary = vec![0; 4];
ancillary.extend(b"tEXt");
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
let mut with_ancillary = b.clone();
let at = b.len() - 12;
with_ancillary.splice(at..at, ancillary);
let png_length = (with_ancillary.len() - 101) as u32;
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
assert!(preview(&with_ancillary).is_ok());
with_ancillary[at + 8] ^= 1;
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
#[test]
fn compressed_metadata_and_animation_are_not_preview_support() {
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
let mut extra = vec![0; 4];
extra.extend(kind);
extra.extend(crc32fast::hash(kind).to_be_bytes());
let mut b = fixture();
let at = b.len() - 12;
b.splice(at..at, extra);
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
}
}
#[test]
fn critical_chunks_follow_a_single_complete_png_layout() {
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
let original = fixture();
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
for kind in kinds {
b.extend([0; 4]);
b.extend(*kind);
b.extend(crc32fast::hash(*kind).to_be_bytes());
}
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
b
}
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
// because its reserved (third-letter) bit is set; future versions can define it.
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
for kinds in [
vec![b"IEND"],
vec![b"IDAT", b"IHDR", b"IEND"],
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
vec![b"IDAT", b"PLTE", b"IEND"],
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
vec![b"ABCD", b"IDAT", b"IEND"],
vec![b"IDAT", b"IEND", b"tEXt"],
] {
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
let _ = header(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
let mut b = fixture();
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
let _ = preview(&b);
}
}
}
mod archive {
use crate::Error;
use crate::container::{Archive, Limits};
use crate::stream::{self, Kind, Tag, Value};
use crate::synth::{self, F, Method, tag};
use proptest::prelude::*;
fn doc() -> Vec<u8> {
synth::stream(&[
(tag(b"Desc"), F::Str("Document".into())),
(tag(b"Visi"), F::Bool(true)),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
(tag(b"Same"), F::Ref(7)),
(tag(b"Null"), F::Null),
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))])),
(tag(b"Enum"), F::Enum(2, 1)),
(tag(b"Tile"), F::Entry("d/3".into())),
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
])
}
#[test]
fn every_method_round_trips_with_checksums() {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.header.version, 12);
assert_eq!(a.read("doc.dat").unwrap(), d);
assert_eq!(a.read("d/3").unwrap(), b"tile");
assert_eq!(a.extracted(), d.len() + 4);
assert!(a.read("missing").is_err());
}
}
#[test]
fn the_stream_parses_into_shared_objects_and_links() {
let s = stream::parse(&doc()).unwrap();
let root = &s.objects[s.root];
assert_eq!(root.class, Tag::of(b"Pers"));
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
assert_eq!(a, b);
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
assert_eq!(s.objects[*a].kind, Kind::Shared);
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
assert_eq!(kids[0], kids[1]);
let Value::Obj(Some(g)) = kids[0] else { panic!() };
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
}
#[test]
fn checksums_sizes_and_budgets_are_enforced() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
let mut broken = file.clone();
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let size_at = fat + 59 + 5 + 8;
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
}
#[test]
fn libraries_and_cyclic_tables_are_rejected() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
let mut library = file.clone();
library[8..12].copy_from_slice(b"rAsA");
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
let at = fat as usize + 4;
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
}
#[test]
fn hostile_streams_fail_cleanly() {
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
// An array claiming four billion elements in a few bytes.
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
assert!(stream::parse(&forward).is_err());
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
assert!(stream::parse(&twice).is_err());
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
let d = doc();
for end in 0..d.len() {
assert!(stream::parse(&d[..end]).is_err(), "{end}");
}
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
// array of one-byte values just past the budget fails the same way, before allocating.
let field = |code: u8, n: u32, data: usize| {
let mut f = vec![0x80 | code];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(n.to_le_bytes());
f.resize(f.len() + data, 0);
F::Raw(f)
};
let bools = 1u32 << 27;
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
let d = synth::stream(&[(tag(b"Big_"), f)]);
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut file = synth::container(&[("doc.dat", &d, method)], None);
for (i, v) in &edits {
let n = file.len();
file[i % n] = *v;
}
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
let _ = stream::parse(&bytes);
}
}
let mut s = d.clone();
for (i, v) in &edits {
let n = s.len();
s[i % n] = *v;
}
let _ = stream::parse(&s);
}
}
}

147
crates/affinity/src/text.rs Normal file
View File

@ -0,0 +1,147 @@
//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application.
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
use crate::paint::{self, Paint};
use crate::stream::{ObjId, Value};
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
const MAX_CHARS: usize = 1 << 20;
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
let mut runs = Vec::new();
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
for block in s.objs(story, b"Blok") {
let glyphs = s.obj(block, b"Glyp")?;
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
for seg in s.objs(glyphs, b"Mixd") {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
}
v
}
};
total = total.checked_add(chars.len())?;
if total > MAX_CHARS {
r.warn("text longer than a million characters (truncated)");
break;
}
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
if end <= start {
continue;
}
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
start = end;
let Some(attrs) = s.obj(run, b"Item") else { continue };
if !text.is_empty() {
runs.push(run_attrs(r, attrs, text, world));
}
}
if align.is_none() {
align = s.obj(block, b"PAtt").and_then(|p| s.objs(p, b"Runs").first().copied()).and_then(|run| s.obj(run, b"Item")).map(|p| paragraph_align(r, p));
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
Align::Right => bounds.x1,
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
match v {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
let ints = s.field(attrs, b"Ints");
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
let objects = match s.field(attrs, b"Objs") {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
_ => Vec::new(),
};
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
}
let font = s.obj(attrs, b"DFnt");
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
size,
tracking,
leading,
fill,
}
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
match slot(r.s.field(p, b"Ints"), 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
Some(0) | None => Align::Left,
Some(_) => {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
}
}

View File

@ -0,0 +1,83 @@
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use photocraft_affinity::{Archive, Limits, stream};
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
files(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
&& std::fs::read(&p).is_ok_and(|b| b.starts_with(photocraft_affinity::MAGIC))
{
out.push(p);
}
}
}
fn samples() -> Option<PathBuf> {
let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
}
#[test]
fn public_documents_parse_completely() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
let mut failures = Vec::new();
for p in &paths {
let bytes = std::fs::read(p).unwrap();
let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
let doc = a.read("doc.dat")?;
stream::parse(&doc).map(|s| s.objects.len())
});
match result {
Ok(n) => assert!(n > 0),
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
eprintln!("{} Affinity documents parsed", paths.len());
}
#[test]
fn public_documents_map_to_the_model() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
let mut failures = Vec::new();
let (mut nodes, mut docs) = (0usize, 0usize);
fn count(n: &[photocraft_affinity::model::Node]) -> usize {
n.iter().map(|n| 1 + count(&n.children)).sum()
}
for p in &paths {
let bytes = std::fs::read(p).unwrap();
if !bytes.starts_with(photocraft_affinity::MAGIC) {
continue;
}
match photocraft_affinity::read(&bytes, Limits::default()) {
Ok(d) => {
docs += 1;
nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::<usize>();
for w in d.warnings {
let key = w.split(" (").next().unwrap_or(&w).to_string();
*warnings.entry(key).or_default() += 1;
}
}
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
eprintln!("{docs} documents, {nodes} nodes");
for (w, n) in &warnings {
eprintln!("{n:4} documents: {w}");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}

View File

@ -14,6 +14,7 @@ photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
photocraft-raster = { workspace = true }
serde = { workspace = true }
rustfft = "6.4.1"
# Parallel tiles on native; wasm builds stay single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

View File

@ -51,6 +51,7 @@ fn main() {
height: 50.0,
white_is_high: true,
},
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
];
for p in cases {
if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) {

View File

@ -1,6 +1,7 @@
//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer.
//! `cargo run --release -p photocraft-algo --example bench_gaussian`
use photocraft_algo::{FilterParams, apply, output_area};
//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer, as a document layer filling the
//! canvas is filtered (edges repeated at the canvas edge).
//! `cargo run --release -p photocraft-algo --example bench_gaussian [-- <size> <radius>]`
use photocraft_algo::{FilterParams, apply_in, output_area};
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
@ -15,8 +16,13 @@ fn main() {
s.write_interleaved(r, &bytes);
let p = FilterParams::GaussianBlur { radius };
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
let dt = t.elapsed();
// Best of a few runs (the first also pays for page faults).
let mut dt = std::time::Duration::MAX;
let mut out = s.clone();
for _ in 0..5 {
let t = std::time::Instant::now();
out = apply_in(&s, &p, area, r, None, r);
dt = dt.min(t.elapsed());
}
println!("gaussian radius {radius} on {n}x{n} RGBA8: {:.2?} ({} tiles)", dt, out.tile_count());
}

Some files were not shown because too many files have changed in this diff Show More