Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 16dd4b19ad fix(layers): identify the Background by structure, never by its English name
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:57:11 -04:00
Brandon ThomasandClaude Opus 5.5 32552a61a1 fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047)
Liquify dabs were spaced a fifth of the brush radius apart and restarted
at every pointer event, so a fast mouse (few far-apart events) and a slow
one (many close ones) warped the same path differently, and a slow drag
piled up one dab per event. Dabs now fall every twentieth of the radius
by distance travelled along the whole stroke, carried from one segment
to the next, as in Photoshop: any sampling of a path gives the same
field. The stationary tools' per-dab strength is scaled to the new
spacing so their effect per distance travelled is unchanged.

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

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

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

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

Refs #2399

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:45:50 -04:00
Brandon ThomasandClaude Opus 5.5 3d0c819d46 Release prep: 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:44:37 -04:00
Brandon ThomasandClaude Opus 5.5 dd708f4ce4 feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip (#4033)
Photoshop writes a Gradient Map's colour stops in the document's own colour
space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such
layers imported as unsupported and did nothing.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 05:22:58 -04:00
bade115202 Fail CLI run when recorded action playback fails (#3861)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 05:14:54 -04:00
Jakub 6d03075ed7 perf: draw marching ants as one mesh instead of a line shape per dash (#3846)
Every ants segment was a white line shape plus a black line shape per 4 px dash, and egui
tessellates each. A selection with many edges seen whole (a speckled selection at 30% zoom:
94,666 segments) cost 45.6 ms per idle frame, ten frames a second while the selection shows.

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

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

Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
2026-10-12 05:14:17 -04:00
zenjabbaandzenjabba ad5fb53f68 Web: avoid shifting canvas during preset saves (#3805)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 05:14:07 -04:00
JGFandClaude Opus 5.5 a2cdc6c2d7 fix(ui): drag a document tab off the strip to float it in its own window (#3733) (#3785)
Dragging a document tab more than 24 pt above or below the tab strip now
tears it off into a separate OS window placed where it was dropped, so it
can be moved anywhere, other screens included (as in Photoshop). Sideways
drags still reorder. Both tab strips share the new tab_drag helper.

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

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

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

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

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

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

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

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

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

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

* chore: opt FredApps into public profile credit

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

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

Replaces the scaffold test with one that fails on main.

---------

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

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

Fixes #3713

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Checked in Photoshop. A click outside still commits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 04:18:33 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 8c6e4a3abc fix(engine): image.canvasSize rejects unlisted anchors instead of landing between two (#2946) (#3572)
anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.

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

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

Fixes #2946.

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

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

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

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

Test only; no production change.

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

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

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

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

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

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

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

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:26:36 -04:00
135 changed files with 10126 additions and 604 deletions

No files matched your search

+8
View File
@@ -257,6 +257,14 @@ jobs:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: psd, name: grd }
- { crate: psd, name: ase }
- { crate: psd, name: aco }
- { crate: psd, name: patterns }
- { crate: psd, name: filter_effects }
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
+1
View File
@@ -19,6 +19,7 @@ next to them.
| `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) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
Generated
+30 -29
View File
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"arboard",
"async-io",
@@ -4224,7 +4224,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4234,7 +4234,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4247,7 +4247,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"base64",
"cap-std",
@@ -4269,7 +4269,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"moxcms",
"rayon",
@@ -4291,7 +4291,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"exr",
"flate2",
@@ -4312,7 +4312,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4320,7 +4320,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"blake3",
"exr",
@@ -4380,9 +4380,10 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"blake3",
"crc32fast",
"flate2",
"photocraft-codecs",
"photocraft-color",
@@ -4399,14 +4400,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"half",
"log",
@@ -4423,14 +4424,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4459,14 +4460,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4477,7 +4478,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4492,7 +4493,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4503,7 +4504,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4513,7 +4514,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"flate2",
"image",
@@ -4528,7 +4529,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4540,7 +4541,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4551,7 +4552,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"flate2",
"parley",
@@ -4565,7 +4566,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"base64",
"craft-ui",
@@ -4604,7 +4605,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4615,7 +4616,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -7448,7 +7449,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2
View File
@@ -331,6 +331,8 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
| 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` |
Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so).
Installers and executables are code-signed.
### macOS
+3
View File
@@ -311,6 +311,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
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}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
+44
View File
@@ -203,6 +203,50 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
+16 -19
View File
@@ -305,27 +305,22 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
if let Some(error) = self.presets.error() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
});
}
self.app.ui(ui, frame);
@@ -382,6 +377,8 @@ fn services(inbox: Inbox) -> Services {
};
reply.send(answer);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// 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());
+9
View File
@@ -644,4 +644,13 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}
+7
View File
@@ -115,6 +115,13 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
+8
View File
@@ -104,8 +104,16 @@ sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true
+16
View File
@@ -59,6 +59,22 @@ fn main() {
prev = p;
}
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
// stroke, and how far the two results differ.
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let mut fields = Vec::new();
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
for step in [2.0, 60.0] {
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
let t = Instant::now();
let mut f = LiquifyField::new(b, cell);
f.apply_stroke(&drag);
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
fields.push(f);
}
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
st.tool = LiquifyTool::Bloat;
let t = Instant::now();
let mut d = Rect::EMPTY;
+196 -56
View File
@@ -154,7 +154,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,6 +165,38 @@ pub struct LiquifyField {
pub d: Vec<[f32; 2]>,
/// Freeze mask per node (0 = free, 1 = frozen).
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
/// transient brush state, not part of the result.
impl PartialEq for LiquifyField {
fn eq(&self, o: &Self) -> bool {
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
}
}
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
/// the brush has travelled along the path since that dab.
#[derive(Clone, Copy, Debug, PartialEq)]
struct StrokeCursor {
end: [f64; 2],
last_dab: [f64; 2],
carry: f64,
}
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
(r * 0.05).max(0.5)
}
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
/// was with dabs a fifth of the radius apart.
fn moving_weight(r: f64) -> f64 {
dab_spacing(r) / (r * 0.2).max(0.5)
}
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
@@ -177,7 +209,7 @@ impl LiquifyField {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -220,6 +252,7 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -306,77 +339,120 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
}
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
/// along the whole stroke, carried from one segment to the next, so the result is the same
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
/// would change nothing), which tests compare with the full walk.
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let stationary = s.tool.is_stationary();
let cur = match self.cursor {
Some(c) if c.end == [a[0], a[1]] => c,
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
};
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
if len == 0.0 {
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
self.cursor = Some(cur);
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
}
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
if !len.is_finite() {
self.cursor = None;
return dirty;
}
let r = (s.size / 2.0).max(0.5);
let spacing = (r * 0.2).max(0.5);
let steps = (len / spacing).ceil().max(1.0) as usize;
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
// touching anything, and a segment to a far-off point has trillions of them (#937).
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
for k in k0..=k1 {
let t0 = (k - 1) as f64 / steps as f64;
let t1 = k as f64 / steps as f64;
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let pr = pa + (pb - pa) * t1;
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&self.dab(s, centre, delta, pr));
let spacing = dab_spacing(r);
let weight = if stationary { moving_weight(r) } else { 1.0 };
// Distance along this segment of its first dab, then one every `spacing`.
let first = (spacing - cur.carry).clamp(0.0, spacing);
// Distances are compared with a little slack: a dab that rounding puts a hair past the
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
let slack = spacing * 1e-6;
if first > len + slack {
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
return dirty;
}
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
let (mut k0, mut k1) = (0.0f64, last_k);
if skip {
match self.reachable_span(a, [dx, dy], len, r) {
// A dragging dab is centred one spacing behind its position: pad by that and a step.
Some((d0, d1)) => {
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
}
None => k1 = -1.0,
}
}
if k0 <= k1 {
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
for k in (k0 as u64)..=(k1 as u64) {
let dist = (first + k as f64 * spacing).min(len);
let c = at(dist);
let delta = [c[0] - prev[0], c[1] - prev[1]];
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if stationary { c } else { prev };
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
prev = c;
}
}
let last = (first + last_k * spacing).min(len);
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
dirty
}
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
/// node only when its centre is within `r` plus a node of the node grid, so the window below
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
/// near that span can touch a node; a segment to a far-off point has trillions of others
/// (#937).
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
if self.w == 0 || self.h == 0 {
return None;
}
let pad = r + 2.0 * self.cell;
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
let (mut u0, mut u1) = (0.0f64, 1.0f64);
for ax in 0..2 {
let d = b[ax] - a[ax];
let d = dir[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
u0 = u0.max(v0.min(v1));
u1 = u1.min(v0.max(v1));
}
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
if u0.is_nan() || u1.is_nan() || u0 > u1 {
return None;
}
Some((k0 as usize, k1 as usize))
Some((u0 * len, u1 * len))
}
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
/// document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
let density = (s.density / 100.0).clamp(0.0, 1.0);
if strength <= 0.0 {
@@ -921,21 +997,9 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every step, whether or not its dab can reach the field.
/// The segment walk before #937: every dab, whether or not it can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let r = (s.size / 2.0).max(0.5);
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
for k in 1..=steps {
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
}
dirty
f.walk_segment(s, a, b, false)
}
#[test]
@@ -945,7 +1009,7 @@ mod tests {
// every result bit-identical to the full walk.
use LiquifyTool::*;
let bounds = Rect::new(-7, 3, 89, 67);
let segments: [([f64; 3], [f64; 3]); 9] = [
let segments: [([f64; 3], [f64; 3]); 10] = [
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
@@ -955,6 +1019,7 @@ mod tests {
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
];
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
for size in [6.0, 40.0] {
@@ -973,6 +1038,81 @@ mod tests {
}
}
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
let mut out = vec![path[0].to_vec()];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
// Uneven steps, as a real pointer gives: t grows quadratically.
for k in 1..=parts {
let t = (k * k) as f64 / (parts * parts) as f64;
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
}
}
out
}
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
/// whole stroke: any sampling of a path gives the same field.
#[test]
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
use LiquifyTool::*;
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
for size in [12.0, 40.0] {
let field = |parts: usize| {
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
let mut s = LiquifyStroke::new(tool, size);
s.points = sampled(&path, parts);
// Some displacement first, so Reconstruct and Smooth have something to change.
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
};
let coarse = field(1);
for parts in [3, 17, 60] {
let fine = field(parts);
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
}
}
}
}
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
/// up differs by a whole dab between a slow and a fast pointer.
#[test]
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
let field = |step: f64| {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
};
let (slow, fast) = (field(2.0), field(60.0));
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
let mut f = LiquifyField::new(bounds(), 2.0);
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
let mut g = LiquifyField::new(bounds(), 2.0);
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
assert_eq!(f, g);
// Hostile points: nothing panics and the next segment starts afresh.
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
}
#[test]
fn a_segment_to_a_far_off_point_returns() {
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
@@ -1110,8 +1250,8 @@ mod tests {
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
#[test]
fn forward_warp_leaves_the_brush_rim_in_place() {
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 4.0);
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
+99
View File
@@ -343,3 +343,102 @@ async fn agent_play_refuses_a_recorded_file_open() {
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges,
/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round
/// trip — all through MCP tools and checked from outside the engine.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_ten_more_scripted_tasks() {
let dir = tmp("tasks2");
let (c, server) = connect(&dir).await;
// 11. Stroke a selection: a red square with a black outside stroke.
tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await;
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await;
let inside = pixel(&c, 40, 40).await;
let edge = pixel(&c, 18, 40).await;
let away = pixel(&c, 5, 5).await;
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}");
assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}");
assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}");
run(&c, "select.deselect", json!({})).await;
// 12. Inverse the selection: a blue frame around the red square.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
let centre = pixel(&c, 40, 40).await;
let frame = pixel(&c, 4, 4).await;
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}");
assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}");
run(&c, "select.deselect", json!({})).await;
// 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default).
run(&c, "layer.new.layer", json!({"name": "Patch"})).await;
run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await;
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.duplicate", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}");
let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count();
assert_eq!(patches, 2, "task 13: {doc}");
// 14. Merge the copy down; the two patches become one layer.
run(&c, "layer.mergeDown", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14");
// 15. Flatten the image; the patch survives.
run(&c, "layer.flattenImage", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}");
let green = pixel(&c, 20, 20).await;
assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}");
// 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels.
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16");
let shifted = pixel(&c, 40, 40).await;
assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}");
run(&c, "layer.delete", json!({})).await;
// 17. Feather the selection and reveal a mask through it: the edge blends.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.modify.feather", json!({"radius": 4})).await;
run(&c, "layer.layerMask.revealSelection", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
let boundary = pixel(&c, 20, 40).await;
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
run(&c, "select.deselect", json!({})).await;
// 18. Expand a selection and read its bounds back.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
run(&c, "select.modify.expand", json!({"radius": 5})).await;
let bounds = inspect(&c).await["selectionBounds"].clone();
let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}"));
let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap());
assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}");
run(&c, "select.deselect", json!({})).await;
// 19. Rename a layer and delete it again.
run(&c, "layer.new.layer", json!({"name": "Scratch"})).await;
run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await;
assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19");
run(&c, "layer.delete", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted");
// 20. Export the final image and reopen the PNG as a flat document.
tool(&c, "doc_export", json!({"path": "final.png"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "final.png"})).await;
let doc = inspect(&c).await;
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20");
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+184
View File
@@ -0,0 +1,184 @@
//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD).
//!
//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked
//! through `doc_inspect`, `document.pixel` and exported/saved files.
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
use rmcp::service::RunningService;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use serde_json::{Value, json};
#[derive(Clone, Default)]
struct Client;
impl ClientHandler for Client {
fn get_info(&self) -> ClientConfig {
ClientConfig::default()
}
}
type Conn = RunningService<RoleClient, Client>;
type Server = tokio::task::JoinHandle<()>;
async fn connect(root: &std::path::Path) -> (Conn, Server) {
let (s, c) = tokio::io::duplex(1 << 20);
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
let server = tokio::spawn(async move {
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
let _ = running.waiting().await;
}
});
(Client.serve(c).await.expect("client init"), server)
}
async fn shutdown(c: Conn, server: Server) {
c.cancel().await.unwrap();
server.await.unwrap();
}
fn text(r: &CallToolResult) -> String {
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
}
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
let mut p = CallToolRequestParams::new(name.to_owned());
if let Value::Object(m) = args {
p = p.with_arguments(m);
}
let r = c.call_tool(p).await.expect("call_tool transport");
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
}
async fn run(c: &Conn, id: &str, params: Value) -> Value {
tool(c, "command_run", json!({"id": id, "params": params})).await
}
async fn inspect(c: &Conn) -> Value {
tool(c, "doc_inspect", json!({})).await
}
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
}
fn top_layer(doc: &Value) -> &Value {
&doc["layers"][0]
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale
/// conversion with an export check, a native save round trip that preserves type, a layer-style
/// stroke and inverse fills.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_six_rounding_out_tasks() {
let dir = tmp("tasks4");
let (c, server) = connect(&dir).await;
// 31. Undo/redo sweep: four fills go away and come back.
tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await;
let h0 = inspect(&c).await["history"].as_array().unwrap().len();
let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"];
for (i, color) in colors.iter().enumerate() {
let x = 10 * i as i32;
run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await;
run(&c, "edit.fill", json!({"color": color})).await;
}
run(&c, "select.deselect", json!({})).await;
let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0;
assert!(steps >= 4, "at least the four fills are history steps: {steps}");
for _ in 0..steps {
run(&c, "edit.undo", json!({})).await;
}
for i in 0..4 {
let x = 10 * i + 5;
let p = pixel(&c, x, 5).await;
assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}");
}
for _ in 0..steps {
run(&c, "edit.redo", json!({})).await;
}
let red = pixel(&c, 5, 5).await;
let black = pixel(&c, 35, 5).await;
assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}");
assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}");
// 32. Paint a barcode: five black bars on white.
tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await;
for i in 0..5 {
let x = 4 + 8 * i;
run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await;
run(&c, "edit.fill", json!({"color": "#000000"})).await;
}
run(&c, "select.deselect", json!({})).await;
for i in 0..5 {
let bar = pixel(&c, 5 + 8 * i, 12).await;
let gap = pixel(&c, 9 + 8 * i, 12).await;
assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}");
assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}");
}
// 33. Convert to grayscale: the channels flatten and the export keeps them equal.
run(&c, "image.mode.grayscale", json!({})).await;
assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33");
let g = pixel(&c, 5, 12).await;
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}");
tool(&c, "doc_export", json!({"path": "bars.png"})).await;
let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap();
let px = img.to_rgba8();
let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4];
assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas");
tool(&c, "doc_close", json!({})).await;
// 34. A native save round trip preserves a styled type layer.
tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await;
run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await;
tool(&c, "doc_save", json!({"path": "type.pcraft"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "type.pcraft"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}");
assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}");
tool(&c, "doc_close", json!({})).await;
// 35. A layer-style stroke frames a swatch.
tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await;
run(&c, "layer.new.layer", json!({"name": "Swatch"})).await;
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}");
let border = pixel(&c, 11, 24).await;
let centre = pixel(&c, 24, 24).await;
assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}");
// 36. Inverse fill: gray around the swatch, then Select All paints everything white.
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#808080"})).await;
run(&c, "select.deselect", json!({})).await;
let corner = pixel(&c, 2, 2).await;
let centre = pixel(&c, 24, 24).await;
assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}");
run(&c, "select.all", json!({})).await;
run(&c, "edit.fill", json!({"color": "#ffffff"})).await;
run(&c, "select.deselect", json!({})).await;
let white = pixel(&c, 2, 2).await;
assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+3 -3
View File
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
}
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
/// named "Background" (how PSD import and File › New create it).
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
pub fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
l.has_background_traits()
}
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
+23
View File
@@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
assert!(got[3] <= 1.0);
}
/// The Background is found by its structure, not its English name: a file from a German or
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
/// bottom layer merely called "Background", without the Background's locks, is not one.
#[test]
fn deep_knockout_reveals_a_localized_background() {
for depth in DEPTHS {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let mut d = doc(depth, true);
d.layers[0].name = name.into();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, WHITE, name);
}
let mut d = doc(depth, true);
d.layers[0].locks = Default::default();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
}
}
+141 -32
View File
@@ -1114,27 +1114,24 @@ fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool, origin:
}
fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
let src = if inner {
match g.source {
GlowSource::Edge => shape.clone().map(|a| 1.0 - a),
GlowSource::Center => shape.clone(),
}
} else {
shape.clone()
};
// An inner glow is built from the edge inwards; a centre-source glow is its inverse, taken
// after the contour and Range (Photoshop 27.11, measured: the centre glow is exactly
// 1 − the edge glow, for both techniques).
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
let mut m = match g.technique {
GlowTechnique::Precise => {
// An inner glow, from either source, measures from the edge inwards; a centre glow is
// the inverse below (the distance outside is ≤ 0 everywhere inside, #966).
// Coverage falls linearly from 0.5 at the edge to 0 at `size + 1` px (centre to
// centre), solid within the spread / choke: after the default 50 % Range that is
// Photoshop's 1 − D / (size + 1) beyond the solid part (measured: outer and inner,
// sizes 10 and 20, spread 0 and 40 %, choke 0 and 30 %: within 1 level; other Range
// values are unmeasured). The distances here are 0.5 px short of centre to centre.
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
let solid = g.size * g.spread;
let soft = (g.size - solid).max(1e-3);
let soft = (g.size + 1.0 - solid).max(1e-3);
let mut m = Map::new(shape.w, shape.h, 0.0);
for (o, d) in m.v.iter_mut().zip(d) {
*o = if d <= solid { 1.0 } else { (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
}
if inner && g.source == GlowSource::Center {
m = m.map(|v| 1.0 - v);
let d = d + 0.5;
*o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
}
m
}
@@ -1142,16 +1139,13 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
if inner && g.source == GlowSource::Center {
// Brightest in the middle: coverage from the distance to the edge.
let mut e = dilate(&shape.clone().map(|a| 1.0 - a), r);
blur(&mut e, bw);
m = e.map(|v| 1.0 - v);
}
m
}
};
m = apply_lut(m, glow_lut(g));
if inner && g.source == GlowSource::Center {
m = m.map(|v| 1.0 - v);
}
noise(&mut m, g.noise, origin.0, origin.1);
if inner {
for (v, a) in m.v.iter_mut().zip(&shape.v) {
@@ -1461,7 +1455,9 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
BevelGeom {
paint,
width,
depth: b.depth * if smooth { width } else { size } * sign,
// A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the
// edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`).
depth: b.depth * if smooth { width } else { size / 2.0 } * sign,
pillow: b.style == BevelStyle::PillowEmboss,
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
}
@@ -1482,10 +1478,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
let sharp = sharp_edges(shape);
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
let mut h = Map::new(shape.w, shape.h, 0.0);
let soft = g.chisel_soft > 0.0;
for i in 0..h.v.len() {
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]);
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
}
if g.chisel_soft > 0.0 {
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
// Hard's across a straight edge); emboss styles keep the blur until measured.
if soft && g.paint == BevelPaint::Both {
tent(&mut h, g.chisel_soft);
}
h
@@ -1527,17 +1526,46 @@ pub struct TextureCtx<'a> {
}
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
///
/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel
/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of
/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp
/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge
/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a
/// pixel further out. All within 1 level of Photoshop on those profiles.
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 {
let ramp = size + 1.0;
match paint {
BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0),
BevelPaint::Both => {
if din > 0.0 {
0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0)
BevelPaint::Outer if !soft => {
let top = (size + 2.0) / ramp;
if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top }
}
// Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel.
BevelPaint::Outer => {
if dout > 0.0 {
1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0)
(size + 2.0) / ramp
}
}
BevelPaint::Both => {
let half = size / 2.0 + 0.5;
if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) }
}
BevelPaint::Inner if soft => {
if din > 0.0 {
((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp)
} else {
0.0
}
}
BevelPaint::Inner => {
if din > 0.0 {
((din + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.0
}
}
BevelPaint::Inner => (din / size).clamp(0.0, 1.0),
}
}
@@ -1555,7 +1583,9 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
// bevel-outer-smooth).
let reach_past = g.paint == BevelPaint::Both;
// Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop
// 27.11 shades it, half strength, at `size + 1½` px out).
let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth);
let on_bevel = |x: i64, y: i64| {
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
};
@@ -2340,6 +2370,40 @@ mod tests {
assert!(at(&maps[0], 20, 15) < 0.05);
}
/// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top
/// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the
/// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard
/// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out.
#[test]
fn chisel_facets_match_photoshop() {
let amount = |g: f32| {
let (se, ce) = 30f32.to_radians().sin_cos();
((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se)
};
let n = 80;
let shape = square(n, 20, 60);
let maps = |style, technique| {
let mut b = bevel_of(style, technique);
b.size = 10.0;
bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0
};
let at = |m: &Map, y: usize| m.v[y * n + 40];
let facet = amount(10.0 / 22.0);
let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
for y in 20..30 {
assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y));
}
assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end");
let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft);
assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20));
assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet");
let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard);
// Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½.
assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4);
assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70));
assert_eq!(at(&outer[1], 72), 0.0);
}
#[test]
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
let shape = square(40, 10, 30);
@@ -2883,6 +2947,51 @@ mod tests {
assert!(glow_lut(&g).is_none());
}
/// Photoshop 27.11, measured on a 128 px square (Normal, black, 100 %, Range 50 %): a precise
/// glow's coverage across the edge, and a centre-source inner glow as 1 − the edge glow.
#[test]
fn glows_match_photoshop() {
let glow = |technique, size: f32, spread: f32, source| Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
paint: FxPaint::Color(photocraft_color::Color::BLACK),
technique,
spread,
size,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source,
};
let n = 60;
let mut shape = Map::new(n, n, 0.0);
for y in 20..40 {
for x in 20..40 {
shape.v[y * n + x] = 1.0;
}
}
let at = |m: &Map, x: usize| m.v[30 * n + x];
// Outer, precise, size 10: Photoshop's black glow over white reads 47, 93, 140, 186, 232
// at 2, 4, 6, 8 and 10 px out from the edge pixel (alpha 1 − D / 11).
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.0, GlowSource::Edge), false, (0, 0));
for (d, ps) in [(2, 47.0f32), (4, 93.0), (6, 140.0), (8, 186.0), (10, 232.0)] {
let got = 255.0 * (1.0 - at(&m, 20 - d));
assert!((got - ps).abs() <= 1.5, "outer precise at {d} px: {got} vs Photoshop {ps}");
}
// Spread 40 %: solid for the first 4 px.
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.4, GlowSource::Edge), false, (0, 0));
assert!((at(&m, 16) - 1.0).abs() < 1e-6 && at(&m, 14) < 1.0);
// Centre source = 1 − edge, for both techniques.
for t in [GlowTechnique::Precise, GlowTechnique::Softer] {
let e = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Edge), true, (0, 0));
let c = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Center), true, (0, 0));
for x in 20..40 {
assert!((at(&c, x) - (1.0 - at(&e, x))).abs() < 1e-5, "{t:?} at {x}");
}
}
}
#[test]
fn offsets_follow_light() {
let (dx, dy) = offset(120.0, 10.0);
+50
View File
@@ -636,6 +636,13 @@ pub struct Layer {
}
impl Layer {
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
/// transparency and position locked. Only the bottom layer of a document with them is the
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
pub fn has_background_traits(&self) -> bool {
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
}
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
Layer {
id: LayerId::fresh(),
@@ -1040,6 +1047,25 @@ impl Document {
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
}
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
/// stores it without a transparency channel and transparency-protected; File › New and opaque
/// flat files create it the same way).
pub fn background(&self) -> Option<&Layer> {
self.layers.first().filter(|l| Layer::has_background_traits(l))
}
/// [`Self::background`], mutably.
pub fn background_mut(&mut self) -> Option<&mut Layer> {
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
}
/// Whether `id` is the document's Background layer ([`Self::background`]).
pub fn is_background(&self, id: LayerId) -> bool {
self.background().is_some_and(|b| b.id == id)
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
@@ -1205,6 +1231,30 @@ mod tests {
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
}
/// The Background is the locked bottom pixel layer, whatever it is called.
#[test]
fn background_is_structural_not_named() {
let mut d = doc();
let id = d.layers[0].id;
assert!(d.is_background(id));
for name in ["Hintergrund", "背景", "Arrière-plan"] {
d.layers[0].name = name.into();
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
}
// A layer called "Background" without the locks, or above the bottom, is not it.
let mut top = Layer::raster("Background", d.pixel_format());
top.locks.transparency = true;
top.locks.position = true;
let top_id = top.id;
d.layers.push(top);
assert!(!d.is_background(top_id));
d.layers[0].locks.position = false;
assert!(d.background().is_none());
d.layers[0].locks.position = true;
d.layers[0].clipped = true;
assert!(d.background_mut().is_none());
}
#[test]
fn max_group_depth_counts_nesting_without_recursing() {
let mut d = doc();
+40
View File
@@ -1224,4 +1224,44 @@ mod tests {
assert!(bad.actions.list.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter,
/// a selection fill and styled text all act on the document the replay runs in.
#[test]
fn a_recorded_edit_run_replays_on_a_second_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
s.execute("actions.record", json!({"name": "Studio"})).unwrap();
s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap();
s.execute("type.setStyle", json!({"fauxBold": true})).unwrap();
let stopped = s.execute("actions.stop", json!({})).unwrap();
let steps = stopped["steps"].as_u64().unwrap();
assert!(steps >= 7, "the recording keeps every family: {stopped}");
// The second document: a different size, background and name. The replay must act here.
s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap();
assert!(played.get("failed").is_none(), "replay: {played}");
assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}");
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document");
assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed");
assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black");
let last = d.layers.last().unwrap();
match &last.content {
photocraft_doc::LayerContent::Text(t) => {
assert_eq!(t.text, "Hi", "the styled text replayed");
assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed");
}
other => panic!("expected the recorded type layer, got {other:?}"),
}
// The first document is untouched.
let first = &s.documents()[0].doc;
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
}
}
+1
View File
@@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
*active = Some(bg.id);
doc.layers = vec![bg];
+1 -1
View File
@@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"angle": 0.0}));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
compound(s, "Straighten", |s| {
+7 -8
View File
@@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::extra_cmds::is_background;
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.new.backgroundFromLayer";
/// The document's Background layer, if it has one (always the bottom layer).
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
doc.background().map(|l| l.id)
}
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
@@ -155,7 +154,7 @@ mod tests {
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
let l = &doc(&s).layers[0];
assert!(is_background(l) && l.name == "Background", "{depth}");
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
assert_eq!(s.active().unwrap().active_layer, Some(a));
// Its pixels stay; transparency takes the background colour (blue).
let red = pixel(&s, 3, 3);
@@ -170,10 +169,10 @@ mod tests {
assert!(s.undo());
assert_eq!(order(&s), [l0, a, b], "{depth}");
let l = doc(&s).layer(a).unwrap();
assert!(!is_background(l) && l.name == "A");
assert!(!doc(&s).is_background(l.id) && l.name == "A");
assert!(s.redo());
assert_eq!(order(&s), [a, l0, b], "{depth}");
assert!(is_background(&doc(&s).layers[0]));
assert!(doc(&s).is_background(doc(&s).layers[0].id));
}
}
@@ -191,7 +190,7 @@ mod tests {
assert_eq!(d.layers[1].id, l0);
assert_eq!(d.layers[2].id, a);
let bg = &d.layers[0];
assert!(is_background(bg) && !bg.visible, "visibility is kept");
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
assert_eq!(bg.opacity, 1.0);
}
@@ -211,7 +210,7 @@ mod tests {
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
// A group or an adjustment layer can't become the Background.
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
@@ -220,7 +219,7 @@ mod tests {
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
assert!(!s.is_enabled(CMD));
assert!(s.execute(CMD, json!({})).is_err());
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
}
#[test]
+23 -28
View File
@@ -415,8 +415,9 @@ pub struct LiveStroke {
symmetry: Option<crate::symmetry_cmds::PaintingSymmetry>,
mirrors: Vec<StrokeRenderer>,
mirror_distinct: Vec<bool>,
/// Restore passes separately; their union may span an otherwise untouched document.
symmetry_regions: Vec<Rect>,
/// What the symmetric preview drew last, once a mirror differs from the stroke, and how many
/// passes it merged (a mirror joining redraws every tile).
union_live: Option<(photocraft_paint::UnionLive, usize)>,
pre: Surface,
sel: Option<Surface>,
lock: bool,
@@ -508,7 +509,7 @@ impl LiveStroke {
symmetry,
mirrors,
mirror_distinct,
symmetry_regions: Vec::new(),
union_live: None,
pre,
sel,
lock,
@@ -567,33 +568,27 @@ impl LiveStroke {
}
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
if self.mirror_distinct.iter().any(|d| *d) {
// Merge every finished pass once, both here and on commit, including shared axes.
// The stroke and its distinct mirrors composite as one stroke, as the commit
// merges them; only the tiles that changed are redrawn.
let old = self.tail;
let mut original_preview = self.renderer.clone();
original_preview.finish();
let copies: Vec<_> = self
.mirrors
.iter()
.zip(&self.mirror_distinct)
.filter(|(_, d)| **d)
.map(|(r, _)| {
let mut r = r.clone();
r.finish();
r
})
let passes = 1 + self.mirror_distinct.iter().filter(|d| **d).count();
let (mut live, full, mut dmg) = match self.union_live.take() {
Some((live, merged)) => (live, merged != passes, Rect::EMPTY),
None => {
// Leaving the single-pass preview: take its smoothing tail back first.
if !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
(photocraft_paint::UnionLive::default(), true, old)
}
};
let mut renderers: Vec<&mut StrokeRenderer> = std::iter::once(&mut self.renderer)
.chain(self.mirrors.iter_mut().zip(&self.mirror_distinct).filter(|(_, d)| **d).map(|(r, _)| r))
.collect();
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
if self.symmetry_regions.is_empty() && !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
for region in &self.symmetry_regions {
surf.write_region(*region, &self.pre.read_region(*region));
}
self.symmetry_regions =
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
self.tail = bounds;
return Ok(damage.union(&bounds));
dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock));
self.tail = old.union(&live.bounds());
self.union_live = Some((live, passes));
return Ok(dmg);
}
let mut dmg = Rect::EMPTY;
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
+2 -1
View File
@@ -608,7 +608,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<st
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
pub fn follows_target(id: &str) -> bool {
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
routed(id) || id == "select.float"
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
routed(id) || id == "select.float" || id == "layer.translate"
}
/// Commands whose target follows the Channels panel.
+2 -2
View File
@@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
if locks.pixels || locks.all {
return Err(format!("the layer \"{}\" is locked", l.name));
}
if locks.transparency && !crate::extra_cmds::is_background(l) {
if locks.transparency && !d.doc.is_background(l.id) {
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
}
Ok(())
@@ -217,7 +217,7 @@ mod tests {
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&s), "Background");
let d = s.active().unwrap();
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
assert!(d.doc.is_background(d.active_layer.unwrap()));
}
#[test]
+2 -2
View File
@@ -878,7 +878,7 @@ fn build() -> Vec<CommandSpec> {
"Move Layer",
[],
None,
r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##,
r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
has_layer,
crate::layer_multi_cmds::translate
),
@@ -1416,7 +1416,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
let from_background = doc.is_background(id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if from_background {
+4 -12
View File
@@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
}
}
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
fn float_background(l: &mut Layer) {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
}
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
let locks = l.locks.union(group);
if locks.all || locks.pixels || (position && locks.position) {
@@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
let canvas = doc.bounds();
let selection = doc.selection.clone();
let group = crate::transform_cmds::group_locks(doc, id);
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
if moves {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
}
if moves {
float_background(l);
}
check_locks(l, group, moves)?;
let LayerContent::Raster(surf) = &mut l.content else {
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
+7 -6
View File
@@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
let mut copies = scratch_doc(&d.doc);
for id in ids {
let mut l = d.doc.layer(*id)?.clone();
if crate::extra_cmds::is_background(&l) {
if d.doc.is_background(*id) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
@@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
/// transparency, so there the area is filled with the background colour instead (Photoshop).
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let bg = doc.is_background(id);
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
if bg {
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
@@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
}
let lowest = visible[0];
let base = doc.layers[lowest].clone();
let is_background = base.name == "Background" && base.locks.transparency;
let is_background = lowest == 0 && doc.is_background(base.id);
// Composite of visible layers only (hidden ones stay where they are).
let mut solo = doc.clone();
solo.layers.retain(|l| l.visible);
@@ -1363,7 +1364,7 @@ mod tests {
let mut s = fresh();
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
}
@@ -1609,9 +1610,9 @@ mod tests {
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), 4);
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
assert!(!crate::extra_cmds::is_background(pasted));
assert!(!st.doc.is_background(pasted.id));
assert_eq!(pasted.locks, Default::default());
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
}
/// #2428: a smart object copied in one document pastes into another as a smart object, in
+5 -16
View File
@@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
}
fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
/// Photoshop converts the Background to a normal layer before erasing to transparency.
fn unlock_background(l: &mut Layer) {
if is_background(l) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
}
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
let locks = doc.effective_locks(l.id);
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
@@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let bg = s.tools.background;
let dmg = s.edit("Magic Eraser", |doc, _| {
let selection = doc.selection.clone();
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
// Photoshop converts the Background to a normal layer before erasing to transparency.
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
crate::extra_cmds::background_to_layer(doc, id);
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
@@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = pixels_locked(&d.doc, l) {
return Err(e);
}
if d.doc.effective_locks(id).transparency && !is_background(l) {
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
}
}
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Background Eraser", |doc, _| {
let selection = doc.selection.clone();
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
unlock_background(l);
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
})?;
+11 -8
View File
@@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
}
pub(crate) fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
fn has_background(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
}
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
@@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
}
@@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
fn layer_from_background(s: &mut Session) -> Result<Value> {
let id = s.edit("Layer From Background", |doc, active| {
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
unlock_background(l);
*active = Some(l.id);
Ok(l.id)
@@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
background_to_layer(doc, id);
}
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
unlock_background(l);
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ impl CutParts {
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
let mut rest = if mask { surf.clone() } else { full.clone() };
let mut piece = Surface::new(with_alpha);
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
let fill = !copy && (mask || doc.is_background(layer));
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
if !b.is_empty() {
@@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
let locks = doc.effective_locks(l.id);
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
locks.all || (locks.position && !doc.is_background(l.id))
}
fn float(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
let gid = match content {
Some(id) => {
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
}
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
+62 -23
View File
@@ -298,7 +298,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
// an extending Crop adds is filled with the extension (background) colour.
if extends
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let old = old_canvas.translate(dx, dy);
@@ -315,22 +315,28 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
Ok(())
}
fn anchor_factors(a: &str) -> (f64, f64) {
let x = if a.contains("Left") || a == "left" {
0.0
} else if a.contains("Right") || a == "right" {
1.0
} else {
0.5
};
let y = if a.starts_with("top") {
0.0
} else if a.starts_with("bottom") {
1.0
} else {
0.5
};
(x, y)
/// The nine documented canvas-size anchors, in the order the command lists them, each mapped to
/// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge).
const ANCHORS: [(&str, (f64, f64)); 9] = [
("topLeft", (0.0, 0.0)),
("top", (0.5, 0.0)),
("topRight", (1.0, 0.0)),
("left", (0.0, 0.5)),
("center", (0.5, 0.5)),
("right", (1.0, 0.5)),
("bottomLeft", (0.0, 1.0)),
("bottom", (0.5, 1.0)),
("bottomRight", (1.0, 1.0)),
];
/// Resolves an anchor name to its (x, y) factors, matching the nine documented values exactly.
/// Anything else is an error naming `anchor` and listing them, rather than resolving the two axes
/// independently (which let a misspelling like `"topleft"` land between two valid anchors, #2946).
fn anchor_factors(cmd: &str, a: &str) -> Result<(f64, f64)> {
ANCHORS.iter().find(|(name, _)| *name == a).map(|&(_, f)| f).ok_or_else(|| {
let names = ANCHORS.iter().map(|&(n, _)| n).collect::<Vec<_>>().join("|");
bad(cmd, format!("`anchor` must be one of {names}"))
})
}
fn extension_color(s: &Session, p: &Value) -> [f32; 4] {
@@ -357,7 +363,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh });
let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) };
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
let (ax, ay) = anchor_factors(p.get("anchor").and_then(Value::as_str).unwrap_or("center"));
let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?;
let dx = ((nw as f64 - ow) * ax).round() as i32;
let dy = ((nh as f64 - oh) * ay).round() as i32;
let ext = extension_color(s, p);
@@ -373,16 +379,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
// A shrink or no-op resize must not silently convert the Background.
if ext[3] == 0.0
&& (nw > ow as u32 || nh > oh as u32)
&& let Some(bg) = doc.layers.first_mut()
&& crate::extra_cmds::is_background(bg)
&& let Some(bg) = doc.background_mut()
{
crate::extra_cmds::unlock_background(bg);
}
// The locked Background layer is extended with the extension colour.
if ext[3] > 0.0
&& let Some(bg) = doc.layers.first_mut()
&& bg.name == "Background"
&& bg.locks.transparency
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let fmt = surf.format();
@@ -980,6 +983,42 @@ mod tests {
assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 20));
}
#[test]
fn canvas_size_anchor_offsets_and_rejects_unlisted() {
// 64x48 -> 100x80: dx spans 36 and dy spans 32, so each anchor factor lands on a known
// offset. All nine documented values must anchor exactly where they do today (#2946).
let expected = [
("topLeft", [0, 0]),
("top", [18, 0]),
("topRight", [36, 0]),
("left", [0, 16]),
("center", [18, 16]),
("right", [36, 16]),
("bottomLeft", [0, 32]),
("bottom", [18, 32]),
("bottomRight", [36, 32]),
];
for (anchor, off) in expected {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
let r = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap();
assert_eq!(r["offset"], json!(off), "anchor {anchor}");
}
// An omitted anchor still defaults to center.
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
assert_eq!(s.execute("image.canvasSize", json!({"width": 100, "height": 80})).unwrap()["offset"], json!([18, 16]));
// A misspelling or unknown value is a BadParams error naming `anchor`, never an anchor
// between two of the nine (the old independent-axis matching produced top-centre etc.).
for anchor in ["topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
let err = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "anchor {anchor:?} must be rejected, got {err:?}");
assert!(err.to_string().contains("anchor"), "the error for {anchor:?} must name `anchor`: {err}");
}
}
#[test]
fn crop_to_selection_and_trim() {
let mut s = session();
+1 -1
View File
@@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let mut l = src_layer.duplicate();
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
if sdoc.is_background(*id) {
l.name = ddoc.next_layer_name("Layer");
l.locks = Default::default();
}
+88 -8
View File
@@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result<Vec<Lay
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
}
@@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
}
pub(crate) fn is_locked_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
}
fn select_all_layers(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
if ids.is_empty() {
return Err(EngineError::Other("there are no layers to select".into()));
}
@@ -283,7 +279,14 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) ->
Ok(())
}
/// Does a move aimed at the layer mask (`"target":"mask"`) move `l`'s mask alone? Only an
/// unlinked mask does (the chain off); a linked one moves with its layer, as in Photoshop.
pub fn moves_mask_alone(l: &Layer) -> bool {
l.mask.as_ref().is_some_and(|m| !m.linked)
}
/// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers.
/// With `"target":"mask"` a layer whose mask is unlinked moves that mask alone.
pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0);
let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0);
@@ -297,14 +300,27 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
if roots.is_empty() {
return Err(EngineError::Other("no active layer".into()));
}
let mask_target = p.get("target").and_then(Value::as_str) == Some("mask");
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let ids = s.edit("Move", |doc, _| {
let ids = move_targets(doc, &roots);
if ids.is_empty() {
let (alone, rest): (Vec<LayerId>, Vec<LayerId>) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone));
let mut ids = move_targets(doc, &rest);
if ids.is_empty() && alone.is_empty() {
return Err(EngineError::NoLayer(roots[0]));
}
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
move_layers(doc, &moves)?;
for id in alone {
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
if let Some(m) = &mut l.mask {
m.surface = shift_surface(&m.surface, dx, dy);
}
ids.push(id);
}
// A board moved past any canvas edge grows the canvas, as a new one does (#1531, #3070).
if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) {
crate::artboard_cmds::fit_canvas(doc);
@@ -326,6 +342,11 @@ pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
/// copying the moving pixels only.
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
moved_target(doc, ids, dx, dy, false)
}
/// [`moved`] for a drag aimed at the layer mask (`mask_target`): unlinked masks move alone.
pub fn moved_target(doc: &Document, ids: &[LayerId], dx: i32, dy: i32, mask_target: bool) -> Result<Document> {
let mut out = doc.clone();
for &id in ids {
let locks = doc.effective_locks(id);
@@ -333,6 +354,12 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Docume
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
if mask_target && moves_mask_alone(l) {
if let Some(m) = &mut l.mask {
m.surface = shift_surface(&m.surface, dx, dy);
}
continue;
}
shift_shown(doc, l, dx, dy);
}
Ok(out)
@@ -1444,6 +1471,59 @@ mod tests {
s.active().unwrap().last_damage
}
/// A mask with its chain off stays put while the layer moves; a linked one moves along.
#[test]
fn translate_leaves_an_unlinked_mask_in_place() {
for depth in [8, 16] {
let mut s = session(depth);
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
s.edit("mask", |doc, _| {
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
Ok(())
})
.unwrap();
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
assert!(mval(&s, 14, 14) > 0.9, "{depth}-bit: unlinked mask stays");
assert!(mval(&s, 44, 14) < 0.1);
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.layerMask.linked", json!({"linked": true})).unwrap();
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
assert!(mval(&s, 44, 14) > 0.9, "{depth}-bit: linked mask moves");
}
}
/// Aimed at the mask, a move shifts an unlinked mask alone and leaves a linked one with its layer.
#[test]
fn translate_with_the_mask_targeted_moves_an_unlinked_mask_alone() {
for depth in [8, 16] {
let mut s = session(depth);
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
s.edit("mask", |doc, _| {
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
Ok(())
})
.unwrap();
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
// Linked: the layer and its mask move together.
s.execute("layer.translate", json!({"dx": 30, "dy": 0, "target": "mask"})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit");
assert!(mval(&s, 44, 14) > 0.9);
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
s.execute("layer.translate", json!({"dx": -30, "dy": 0, "target": "mask"})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit: the layer stays");
assert!(mval(&s, 14, 14) > 0.9, "the mask moved");
assert!(mval(&s, 44, 14) < 0.1);
s.execute("edit.undo", json!({})).unwrap();
assert!(mval(&s, 44, 14) > 0.9, "undo puts the mask back");
}
}
#[test]
fn moves_and_layer_props_report_only_their_area() {
let mut s = session(8);
+2 -1
View File
@@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
let canvas = doc.bounds();
let selection = doc.selection.clone();
let locks = doc.effective_locks(id);
let background = doc.is_background(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
@@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).
if float_bg && l.locks.position && l.name == "Background" {
if float_bg && background {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
+2 -1
View File
@@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
let canvas = doc.bounds();
let fmt = doc.pixel_format();
let dpi = doc.resolution_dpi;
let background_id = doc.background().map(|l| l.id);
for l in doc.layers.iter_mut() {
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
let background = Some(l.id) == background_id;
if background && let Some(surf) = l.surface_mut() {
// The Background stays a Background: rotated pixels over the background colour.
let src = surf.content_bounds();
+7 -5
View File
@@ -14,7 +14,7 @@
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -154,12 +154,13 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<
}
/// Maps every layer's pixels and masks through `h` into `clip`.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
/// `background_id` is the id of the document's Background, if it has one.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32], background_id: Option<LayerId>) -> Result<()> {
for l in layers {
crate::allocation::checkpoint("a perspective crop")?;
let background = crate::extra_cmds::is_background(l);
let background = background_id == Some(l.id);
match &mut l.content {
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?,
LayerContent::Raster(surf) => {
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
*surf = if background {
@@ -188,7 +189,8 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32)
let (w, ht) = size;
let clip = Rect::from_xywh(0, 0, w, ht);
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
let background_id = doc.background().map(|l| l.id);
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
}
+1 -1
View File
@@ -126,7 +126,7 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
let canvas = doc.bounds();
for (path, _, l) in doc.walk().iter().rev() {
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|| crate::layer_multi_cmds::is_locked_background(l)
|| doc.is_background(l.id)
|| doc.effective_locks(l.id).all
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
{
+2 -6
View File
@@ -191,13 +191,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mode = doc.mode;
let effects: Vec<_> = effects.iter().cloned().map(|e| e.in_mode(mode)).collect();
for id in &ids {
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
// Styles can't sit on the Background: Photoshop turns it into a normal layer first.
if l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
crate::extra_cmds::background_to_layer(doc, *id);
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
if add {
l.effects.items.extend(effects.iter().cloned());
} else {
+1 -1
View File
@@ -50,7 +50,7 @@ fn has_layer_for_slice(s: &Session) -> std::result::Result<(), String> {
if matches!(l.content, LayerContent::Adjustment(_)) {
return Err("adjustment layers have no bounds to slice".into());
}
if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) {
if d.doc.is_background(id) {
return Err("the Background layer can't have a layer-based slice".into());
}
if d.doc.slices.list.iter().any(|s| s.layer == Some(id)) {
+1 -1
View File
@@ -666,7 +666,7 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool {
/// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so
/// the document looks the same before and after.
pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result<Layer> {
let background = l.name == "Background" && l.locks.position;
let background = doc.is_background(l.id);
let mut inner = l.clone();
inner.visible = true;
inner.opacity = 1.0;
+2 -1
View File
@@ -32,9 +32,10 @@ fn contents(s: &Session, doc: &Document, sm: &SmartObject, intent: Intent, bpc:
if src.depth != doc.depth {
crate::image_cmds::convert_layers_depth(&mut src.layers, doc.depth);
}
let background_id = src.background().map(|l| l.id);
let mut layers = std::mem::take(&mut src.layers);
for l in &mut layers {
if crate::extra_cmds::is_background(l) {
if Some(l.id) == background_id {
l.name = "Layer 0".into();
l.locks = Default::default();
}
+12 -15
View File
@@ -201,10 +201,10 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
}
/// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's
/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]).
fn transform_locks(l: &Layer, group: Locks) -> Locks {
/// position lock doesn't count: Photoshop turns it into a normal layer first.
fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks {
let mut own = l.locks;
if own.position && l.name == "Background" {
if background {
own.position = false;
own.transparency = false;
}
@@ -217,29 +217,24 @@ fn transform_locks(l: &Layer, group: Locks) -> Locks {
pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
let Some(l) = doc.layer(id) else { return false };
// An explicit stack rather than recursion: documents can nest deeply.
let mut stack = vec![(l, group_locks(doc, id))];
while let Some((l, group)) = stack.pop() {
let locks = transform_locks(l, group);
let mut stack = vec![(l, group_locks(doc, id), doc.is_background(id))];
while let Some((l, group, background)) = stack.pop() {
let locks = transform_locks(l, group, background);
if locks.position || locks.all {
return true;
}
if let LayerContent::Group(g) = &l.content {
stack.extend(g.children.iter().map(|c| (c, locks)));
stack.extend(g.children.iter().map(|c| (c, locks, false)));
}
}
false
}
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
crate::allocation::checkpoint("transforming pixels")?;
let locks = transform_locks(l, group);
// Photoshop turns the Background into a normal layer before transforming it.
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
let locks = transform_locks(l, group, false);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
@@ -493,6 +488,8 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = group_locks(doc, id);
// Photoshop turns the Background into a normal layer before transforming it.
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
if !h.is_identity() {
+42 -2
View File
@@ -121,10 +121,23 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
Ok(())
}
/// The PostScript name of the installed face of `family` at exactly `weight`/`italic`. A PSD names
/// a type layer by PostScript name, and Photoshop treats a name no font has as missing (#3556).
/// Only an exact face is named: layout picks a named face before weight and italic, so naming a
/// nearby face would override the requested weight (a variable font's axis, a weight between two
/// faces) or italic. Without an exact face the style keeps no name and layout matches as before.
fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option<String> {
let mut shared = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let faces = shared.fonts.faces(family);
let face = faces.iter().find(|f| f.italic == italic && f.weight.round() as u16 == weight)?;
face.postscript_name.clone()
}
/// Applies character-style keys from JSON. Returns true if any key was present.
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
let mut any = false;
let mut hit = |b: bool| any |= b;
let font_key = ["font", "family", "fontStyle", "weight", "italic"].iter().any(|k| p.get(k).is_some());
if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) {
s.font_family = v.to_string();
s.postscript_name = None;
@@ -169,6 +182,11 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
s.postscript_name = Some(v.to_string());
hit(true);
}
// A font named by family (or a style that matched no face) has no PostScript name yet: fill in
// the installed face's real name, so the PSD does not invent "<Family>-Regular" (#3556).
if font_key && s.postscript_name.is_none() {
s.postscript_name = installed_postscript(&s.font_family, s.weight, s.italic);
}
if let Some(v) = f32p(p, "size") {
s.size_pt = v.max(0.01);
hit(true);
@@ -989,14 +1007,36 @@ mod tests {
assert_eq!(style.weight, 600);
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
apply_char_props(&mut style, &json!({"weight": 400}));
assert!(style.postscript_name.is_none());
// #3556: a style change re-resolves to the installed face instead of leaving it nameless.
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
assert!(style.font_style.is_empty());
apply_char_props(&mut style, &json!({"fontStyle": "unknown-style"}));
assert!(style.postscript_name.is_none());
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
apply_char_props(&mut style, &json!({"fontStyle": "Regular", "postscriptName": "ExplicitFace"}));
assert_eq!(style.postscript_name.as_deref(), Some("ExplicitFace"));
}
/// #3556: a font named by family stores the installed face's PostScript name, so PSD export
/// names a real font instead of inventing "<Family>-Regular" that Photoshop calls missing.
#[test]
fn a_family_font_gets_the_installed_postscript_name() {
let mut style = CharStyle { font_family: "Inter".into(), ..Default::default() };
apply_char_props(&mut style, &json!({"font": "Inter"}));
assert_eq!(style.font_family, "Inter");
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"), "resolved from the font database");
// A weight picks that face's name.
apply_char_props(&mut style, &json!({"font": "Inter", "weight": 600}));
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
// A weight no face has (a variable font's axis, or between two faces) names no face, so
// layout still matches the requested weight instead of a nearby face's.
apply_char_props(&mut style, &json!({"weight": 650}));
assert!(style.postscript_name.is_none(), "{:?}", style.postscript_name);
// A family PhotoCraft does not have keeps no name; the export falls back as before.
let mut absent = CharStyle { font_family: "No Such Family 123".into(), ..Default::default() };
apply_char_props(&mut absent, &json!({"font": "No Such Family 123"}));
assert!(absent.postscript_name.is_none());
}
/// Picking a style or weight resets the weight axis to it; other axes keep their values (#2985).
#[test]
fn a_style_or_weight_resets_the_weight_axis() {
+3 -5
View File
@@ -110,12 +110,9 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
Ok(w)
}
/// A Background is turned into a normal layer by the caller first
/// ([`crate::extra_cmds::background_to_layer`]).
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
let locks = l.locks.union(group);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
@@ -270,6 +267,7 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let sel = doc.selection.clone();
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = crate::transform_cmds::group_locks(doc, id);
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?;
let snapshot = doc.clone();
+2
View File
@@ -19,6 +19,8 @@ serde = { workspace = true }
serde_json = { workspace = true, features = ["unbounded_depth"] }
thiserror = { workspace = true }
flate2 = { workspace = true }
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
crc32fast = "1.5"
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
ruzstd = "0.9"
+68 -20
View File
@@ -3,6 +3,7 @@
//! unzipped and re-zipped by another tool still loads). No ZIP64, no
//! encryption, no multi-disk archives.
use std::collections::HashMap;
use std::io::Read;
use crate::FormatError;
@@ -11,25 +12,9 @@ const LOCAL_SIG: u32 = 0x0403_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const EOCD_SIG: u32 = 0x0605_4b50;
/// CRC-32 (IEEE), table-driven.
/// CRC-32 (IEEE), as stored in ZIP headers.
pub fn crc32(data: &[u8]) -> u32 {
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
let t = TABLE.get_or_init(|| {
let mut t = [0u32; 256];
for (i, e) in t.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
*e = c;
}
t
});
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
}
!c
crc32fast::hash(data)
}
/// Streaming writer for STORE entries.
@@ -122,7 +107,12 @@ pub struct Entry {
/// Read-only view of an archive in memory.
pub struct ZipReader<'a> {
bytes: &'a [u8],
/// All entries, in central-directory order. Treat as read-only: `find`
/// looks names up through an index built from this list in `new`.
pub entries: Vec<Entry>,
/// Name -> index of the first entry with that name, so `find` is O(1)
/// instead of a scan per lookup (a bundle reads one entry per tile).
by_name: HashMap<String, usize>,
}
fn u16_at(b: &[u8], o: usize) -> Result<u16, FormatError> {
@@ -194,11 +184,16 @@ impl<'a> ZipReader<'a> {
entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset });
p = entry_end;
}
Ok(ZipReader { bytes, entries })
let mut by_name = HashMap::with_capacity(entries.len());
for (i, e) in entries.iter().enumerate() {
// Duplicate names: the first entry wins, as with a front-to-back scan.
by_name.entry(e.name.clone()).or_insert(i);
}
Ok(ZipReader { bytes, entries, by_name })
}
pub fn find(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name == name)
self.by_name.get(name).and_then(|&i| self.entries.get(i))
}
/// Entry payload (decompressed), at most `max` bytes, CRC-checked.
@@ -263,6 +258,59 @@ mod tests {
assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_))));
}
#[test]
fn crc_matches_bitwise_reference() {
// Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross
// any SIMD / slicing block size.
fn reference(data: &[u8]) -> u32 {
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
let data: Vec<u8> = (0..1000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
for len in [0, 1, 7, 15, 16, 17, 63, 64, 65, 255, 256, 1000] {
assert_eq!(crc32(&data[..len]), reference(&data[..len]), "len {len}");
}
}
#[test]
fn find_locates_every_entry_in_a_large_archive() {
let mut w = ZipWriter::new();
let n = 3000u32;
for i in 0..n {
w.add(&format!("tiles/{i:05}.zst"), &i.to_le_bytes()).unwrap();
}
let bytes = w.finish().unwrap();
let r = ZipReader::new(&bytes).unwrap();
assert_eq!(r.entries.len(), n as usize);
for i in (0..n).rev() {
let name = format!("tiles/{i:05}.zst");
let e = r.find(&name).unwrap();
assert_eq!(e.name, name);
assert_eq!(r.read(e, 4).unwrap(), i.to_le_bytes());
}
assert!(r.find("tiles/missing.zst").is_none());
assert!(r.find("").is_none());
}
#[test]
fn duplicate_names_resolve_to_the_first_entry() {
let mut w = ZipWriter::new();
w.add("a", b"first").unwrap();
w.add("b", b"other").unwrap();
w.add("a", b"second").unwrap();
let bytes = w.finish().unwrap();
let r = ZipReader::new(&bytes).unwrap();
assert_eq!(r.entries.len(), 3);
assert_eq!(r.read_by_name("a", 100).unwrap(), b"first");
assert_eq!(r.read_by_name("b", 100).unwrap(), b"other");
}
#[test]
fn crc_mismatch_detected() {
let mut w = ZipWriter::new();
+35 -15
View File
@@ -1152,31 +1152,33 @@ fn fs_mblur(in: VOut) -> @location(0) vec4<f32> {
return mout(acc);
}
// Precise glow from a distance map: p0 = (solid, soft, invert).
// Precise glow from a distance map (`effects::glow_map`): p0 = (solid, soft). Coverage is 0.5 at
// the edge falling to 0 at `size + 1` px centre to centre (the field is 0.5 px short of that),
// solid within the spread / choke.
@fragment
fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
let d = textureLoad(tex_a, local(in.pos), 0).r;
var m = clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5;
var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
if (d <= op.p0.x) { m = 1.0; }
if (op.p0.z > 0.5) { m = 1.0 - m; }
return mout(m);
}
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v, contour (LUT row 0), × shape
// (`layer_tex`)).
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v (1: before the contour, 2: after it,
// as a centre-source inner glow), contour (LUT row 0), × shape (`layer_tex`)).
@fragment
fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
var v = textureLoad(tex_a, p, 0).r;
if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); }
if (op.p0.y > 0.5) { v = 1.0 - v; }
if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; }
if (op.p0.z > 0.5) { v = lut(0, v); }
if (op.p0.y > 1.5) { v = 1.0 - v; }
if (op.p0.w > 0.5) { v = v * textureLoad(layer_tex, p, 0).r; }
return mout(v);
}
// Chiselled bevel height from A = dist_inside, B = dist_outside (`effects::bevel_chisel_h`):
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size).
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size, Chisel Soft).
@fragment
fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
@@ -1184,16 +1186,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
let dout = textureLoad(tex_b, p, 0).r;
let size = op.p0.y;
var h: f32;
// The ramp spans size + 1 px, from half a pixel beyond the edge (`effects::bevel_chisel_h`).
let ramp = size + 1.0;
let soft = op.p0.z > 0.5;
switch i32(op.p0.x) {
case 0: { h = 1.0 - clamp(dout / size, 0.0, 1.0); }
case 1: {
if (din > 0.0) {
h = 0.5 + 0.5 * clamp(din / (size / 2.0), 0.0, 1.0);
case 0: {
if (soft) {
h = select((size + 2.0) / ramp, 1.0 - clamp((dout + 0.5) / ramp, 0.0, 1.0), dout > 0.0);
} else {
h = 0.5 - 0.5 * clamp(dout / (size / 2.0), 0.0, 1.0);
let top = (size + 2.0) / ramp;
h = select(top, clamp((size + 1.5 - dout) / ramp, 0.0, top), dout > 0.0);
}
}
case 1: {
let half = size / 2.0 + 0.5;
if (din > 0.0) {
h = 0.5 + 0.5 * clamp(din / half, 0.0, 1.0);
} else {
h = 0.5 - 0.5 * clamp(dout / half, 0.0, 1.0);
}
}
default: {
if (soft) {
h = select(0.0, clamp((din + 1.5) / ramp, 0.0, (size + 2.0) / ramp), din > 0.0);
} else {
h = select(0.0, clamp((din + 0.5) / ramp, 0.0, 1.0), din > 0.0);
}
}
default: { h = clamp(din / size, 0.0, 1.0); }
}
return mout(h);
}
@@ -1207,7 +1226,8 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
// 4-neighbour's).
var hmax = ra(p);
if (op.p1.y > 1.5) {
// p3.x: an outer chisel bevel, which also reaches one pixel past its ramp.
if (op.p1.y > 1.5 || op.p3.x > 0.5) {
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
}
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
+22 -6
View File
@@ -236,6 +236,19 @@ impl B {
fn finish(&mut self, a: In, b: Option<In>, invert: bool, contour: &Contour, times_shape: bool, out: usize) {
self.finish_lut(a, b, invert, contour_lut(contour), times_shape, out);
}
/// A glow's last step: its contour over the Range, then (centre-source inner glows) the
/// inverse, then × the shape when inner.
fn finish_glow(&mut self, a: In, lut: Option<Vec<f32>>, inner: bool, center: bool) {
let mut s =
stage(Kernel::MFinish, Some(a), None, [0.0, if center { 2.0 } else { 0.0 }, f32::from(u8::from(lut.is_some())), f32::from(u8::from(inner))], 0);
s.lut = lut.map(Arc::new);
if inner {
s.s = Some(In::Shape);
}
s.out = Some(0);
self.stages.push(s);
}
fn finish_lut(&mut self, a: In, b: Option<In>, invert: bool, lut: Option<Vec<f32>>, times_shape: bool, out: usize) {
let flag = |v: bool| f32::from(u8::from(v));
let mut s = stage(Kernel::MFinish, Some(a), b, [flag(b.is_some()), flag(invert), flag(lut.is_some()), flag(times_shape)], 0);
@@ -319,9 +332,9 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
GlowTechnique::Precise => {
let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size);
let solid = g.size * g.spread;
let soft = (g.size - solid).max(1e-3);
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0));
b.finish_lut(m, None, false, photocraft_compose::effects::glow_lut(g), inner, 0);
let soft = (g.size + 1.0 - solid).max(1e-3);
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, 0.0, 0.0], 0));
b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
}
GlowTechnique::Softer => {
// Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha.
@@ -333,7 +346,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
m = b.dilate(src, d, r);
}
let m = b.blur(m, bw);
b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0);
b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
}
}
1
@@ -363,8 +376,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
} else {
let din = b.field(FieldKind::BevelInside, size);
let dout = b.field(FieldKind::BevelOutside, size);
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0));
if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
let soft = g.chisel_soft > 0.0;
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0));
// As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp.
if soft && g.paint == BevelPaint::Both { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
};
if let Some(c) = &bv.contour {
// Contour element: the height through the contour (`bevel_contour_lut`).
@@ -405,6 +420,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
for which in [0.0, 1.0] {
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
s.p3 = [f32::from(u8::from(g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth)), 0.0, 0.0, 0.0];
s.lut = lut.clone();
s.s = Some(In::Shape);
s.out = Some(out);
+107 -6
View File
@@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option<u32> {
/// key), reverse, dither, [method], name, colour stops (location /4096, midpoint %, colour
/// space + four u16 components, 2 bytes), transparency stops, then smoothness (/4096) among
/// noise-gradient fields. Smoothness, midpoints and the method are baked into dense stops like
/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported.
/// gradient fills. Stops may be in any plain colour space Photoshop writes (RGB, HSB, CMYK, Lab,
/// Grayscale: a CMYK or Lab document stores them in its own space); they are converted to RGB
/// through the colour-managed conversions (the document's CMYK profile when one is in scope).
/// Noise gradients (no colour stops) and stops in other spaces (book colours) stay unsupported.
fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
let version = be16(d, 0)?;
let reverse = *d.get(2)? != 0;
@@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
for _ in 0..n {
let loc = (be32(d, at)? as f32 / 4096.0).clamp(0.0, 1.0);
let mid = be32(d, at + 4)? as f32 / 100.0;
if be16(d, at + 8)? != 0 {
return None;
}
let c = |k: usize| be16(d, at + 10 + 2 * k).map(|v| f32::from(v) / 65535.0);
stops.push((loc, photocraft_color::Color::rgb(c(0)?, c(1)?, c(2)?)));
let space = be16(d, at + 8)?;
let c = |k: usize| be16(d, at + 10 + 2 * k);
stops.push((loc, crate::channel_map::decode_known_color(space, [c(0)?, c(1)?, c(2)?, c(3)?])?));
mids.push(mid);
at += 20;
}
@@ -309,6 +310,23 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
Some(Adjustment::GradientMap { stops, reverse, dither })
}
/// Points an imported `grdm` block at `target` when only Reverse and/or Dither differ (the
/// stops parse unchanged): patches those two bytes and returns `true`, so the file's own stops
/// (in whatever colour space it stored them), smoothness and method survive the save. `false`
/// leaves `data` untouched.
pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channels: Channels) -> bool {
let Adjustment::GradientMap { stops, reverse, dither } = target else { return false };
let same_stops = matches!(parse(b"grdm", data, None, channels), Adjustment::GradientMap { stops: s, .. } if s == *stops);
match (same_stops, data.get_mut(2..4)) {
(true, Some(flags)) => {
flags[0] = u8::from(*reverse);
flags[1] = u8::from(*dither);
true
}
_ => false,
}
}
/// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so
/// the stops are reproduced exactly).
fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec<u8> {
@@ -824,6 +842,89 @@ mod tests {
assert_eq!(write(&a)[0].1, block);
}
/// A two-stop `grdm` (version 1) whose stops are the colour structures `a` and `b`.
fn grdm_with(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
let mut v = write_gradient_map(&[(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false);
// Header 4 + name length 4 + "Custom\0" 14 + stop count 2; 20 bytes per stop.
for (i, (space, c)) in [a, b].into_iter().enumerate() {
let at = 24 + 20 * i + 8;
v[at..at + 2].copy_from_slice(&space.to_be_bytes());
for (k, x) in c.iter().enumerate() {
v[at + 2 + 2 * k..at + 4 + 2 * k].copy_from_slice(&x.to_be_bytes());
}
}
v
}
fn ends(a: &Adjustment) -> ([f32; 3], [f32; 3]) {
match a {
Adjustment::GradientMap { stops, .. } => (stops[0].1, stops[stops.len() - 1].1),
other => panic!("{other:?}"),
}
}
#[test]
fn gradient_map_stops_in_every_colour_space() {
use photocraft_color::{Color, ColorMode};
let white_rgb = (0, [65535, 65535, 65535, 0]);
// CMYK, stored inverted: 65535 = no ink. Full K only.
let k_only = grdm_with((2, [65535, 65535, 65535, 0]), white_rgb);
let expected = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }.to_rgb();
for channels in [Channels::Cmyk, Channels::Rgb, Channels::Lab] {
let a = parse(b"grdm", &k_only, None, channels);
let (first, last) = ends(&a);
assert!(first.iter().zip(expected).all(|(x, y)| (x - y).abs() < 1e-5), "{first:?} vs {expected:?}");
assert!(first[0] < 0.4 && (first[0] - first[2]).abs() < 0.05, "K-only black is a dark neutral: {first:?}");
assert_eq!(last, [1.0; 3]);
}
// Lab: L 50, a 0, b 0 is a neutral mid grey; L 100 white.
let lab = parse(b"grdm", &grdm_with((7, [5000, 0, 0, 0]), (7, [10000, 0, 0, 0])), None, Channels::Lab);
let (first, last) = ends(&lab);
assert!(first.iter().all(|v| (v - 0.466).abs() < 0.01), "{first:?}");
assert!(last.iter().all(|v| *v > 0.99), "{last:?}");
// Lab with chroma: a = +60 is reddish (a and b are signed hundredths).
let red = parse(b"grdm", &grdm_with((7, [5000, 6000, 0, 0]), white_rgb), None, Channels::Lab);
let (first, _) = ends(&red);
assert!(first[0] > first[1] + 0.2, "{first:?}");
// Grayscale: 0..10000 is ink coverage (10000 = black).
let gray = parse(b"grdm", &grdm_with((8, [10000, 0, 0, 0]), (8, [0, 0, 0, 0])), None, Channels::Gray);
assert_eq!(ends(&gray), ([0.0; 3], [1.0; 3]));
// HSB: hue 240 degrees (2/3 of 65535), full saturation and brightness = blue.
let hsb = parse(b"grdm", &grdm_with((1, [43690, 65535, 65535, 0]), white_rgb), None, Channels::Rgb);
let (first, _) = ends(&hsb);
assert!(first[0] < 1e-3 && first[1] < 1e-3 && (first[2] - 1.0).abs() < 1e-3, "{first:?}");
// Book colours and unknown ids are not guessed: the block stays raw.
for space in [3u16, 9, 0xFFFF] {
let d = grdm_with((space, [1, 2, 3, 4]), white_rgb);
assert!(matches!(parse(b"grdm", &d, None, Channels::Rgb), Adjustment::Unsupported { .. }), "space {space}");
}
// Truncated stops never panic.
for n in 0..k_only.len() {
let _ = parse(b"grdm", &k_only[..n], None, Channels::Cmyk);
}
}
#[test]
fn gradient_map_reverse_and_dither_keep_the_stops_block() {
let original = grdm_with((2, [65535, 65535, 65535, 0]), (7, [10000, 0, 0, 0]));
let Adjustment::GradientMap { stops, .. } = parse(b"grdm", &original, None, Channels::Cmyk) else { panic!() };
let mut data = original.clone();
let target = Adjustment::GradientMap { stops: stops.clone(), reverse: true, dither: true };
assert!(retarget_gradient_map(&mut data, &target, Channels::Cmyk));
assert_eq!(&data[2..4], &[1, 1]);
assert_eq!(data[4..], original[4..], "the stops keep their colour spaces");
assert_eq!(parse(b"grdm", &data, None, Channels::Cmyk), target);
// Edited stops are not patched in.
let mut edited = stops;
edited[0].1 = [0.5; 3];
let mut data2 = original.clone();
let target = Adjustment::GradientMap { stops: edited, reverse: false, dither: false };
assert!(!retarget_gradient_map(&mut data2, &target, Channels::Cmyk));
assert_eq!(data2, original);
assert!(!retarget_gradient_map(&mut [], &target, Channels::Cmyk));
assert!(!retarget_gradient_map(&mut data2, &Adjustment::Invert, Channels::Cmyk));
}
fn rt(a: Adjustment) {
let blocks = write(&a);
assert!(!blocks.is_empty(), "{a:?}");
+1
View File
@@ -151,6 +151,7 @@ impl Builder {
bg.prune();
let mut l = Layer::new("Background", LayerContent::Raster(bg));
l.locks.transparency = true;
l.locks.position = true;
layers.insert(0, l);
}
self.doc.layers = layers;
+70 -13
View File
@@ -15,13 +15,21 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007;
pub(crate) const QUICK_MASK_INFO: u16 = 1022;
const SPACE_RGB: u16 = 0;
const SPACE_HSB: u16 = 1;
const SPACE_CMYK: u16 = 2;
const SPACE_LAB: u16 = 7;
const SPACE_GRAY: u16 = 8;
/// Resource 1077 for `channels` (in file order).
pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec<u8> {
/// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour
/// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13;
/// 14-byte records (the older 1007 layout) shift every channel after the first.
/// `transparency`: the merged image's transparency channel comes first and gets a record too.
pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Vec<u8> {
let mut out = 1u32.to_be_bytes().to_vec();
if transparency {
// As Photoshop writes it: red, 100 %, masked areas.
out.extend_from_slice(&[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 100, 1]);
}
for c in channels {
let (color, opacity, kind) = match c.spot {
Some((ink, solidity)) => (ink, solidity, 2u8),
@@ -34,15 +42,16 @@ pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec<u8> {
}
out.extend_from_slice(&((opacity.clamp(0.0, 1.0) * 100.0).round() as u16).to_be_bytes());
out.push(kind);
out.push(0);
}
out
}
/// Applies a DisplayInfo resource (`versioned` = 1077) to the imported channels in order.
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [AlphaChannel]) {
let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data };
for (c, rec) in channels.iter_mut().zip(body.as_chunks::<14>().0) {
/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the
/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip`
/// records (the merged transparency channel's).
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) {
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) {
let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]);
let comps = [u(2), u(4), u(6), u(8)];
let color = decode_color(u(0), comps);
@@ -58,9 +67,25 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [A
}
}
/// A PSD colour structure (space id + four u16 components) as an RGB [`Color`]; unknown spaces
/// (book colours…) read the first three components as RGB. See [`decode_known_color`].
pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
let n = |v: u16| f32::from(v) / 65535.0;
match space {
decode_known_color(space, c).unwrap_or_else(|| Color::rgb(n(c[0]), n(c[1]), n(c[2])))
}
/// A PSD colour structure in one of the spaces Photoshop writes for plain colours (0 RGB,
/// 1 HSB, 2 CMYK, 7 Lab, 8 Grayscale) as an RGB [`Color`], or `None` for any other space id.
/// CMYK goes through the colour-managed conversion (the active document CMYK profile, see
/// `photocraft_color::convert::with_cmyk_space`), Lab through the D50 Lab → sRGB conversion.
pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option<Color> {
let n = |v: u16| f32::from(v) / 65535.0;
Some(match space {
SPACE_RGB => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
SPACE_HSB => {
let rgb = hsb_to_rgb(n(c[0]) * 360.0, n(c[1]), n(c[2]));
Color::rgb(rgb[0], rgb[1], rgb[2])
}
// Photoshop colour structures store CMYK inverted (65535 = no ink).
SPACE_CMYK => {
let cmyk = Color { mode: ColorMode::Cmyk, c: [1.0 - n(c[0]), 1.0 - n(c[1]), 1.0 - n(c[2]), 1.0 - n(c[3])], alpha: 1.0 };
@@ -79,7 +104,23 @@ pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0;
Color::rgb(v, v, v)
}
_ => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
_ => return None,
})
}
/// HSB (hue in degrees, saturation and brightness 0..1) → RGB.
fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 };
let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
let f = h - h.floor();
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
match h.floor() as u8 {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
@@ -97,17 +138,25 @@ mod tests {
a.indicates = ColorIndicates::SelectedAreas;
let mut s = AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8));
s.spot = Some((Color::rgb(1.0, 0.0, 1.0), 0.8));
let bytes = display_info(&[&a, &s]);
assert_eq!(bytes.len(), 4 + 28);
let bytes = display_info(&[&a, &s], false);
// 13 bytes per channel, as every corpus file (Photoshop's included) lays out 1077.
assert_eq!(bytes.len(), 4 + 2 * 13);
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
apply_display_info(&bytes, true, &mut back);
apply_display_info(&bytes, true, 0, &mut back);
assert_eq!(back[0].color, a.color);
assert!((back[0].opacity - 0.3).abs() < 1e-6);
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
assert_eq!(back[1].spot, s.spot);
// With the merged transparency's record first, the channels still get their own.
let with_t = display_info(&[&a, &s], true);
assert_eq!(with_t.len(), 4 + 3 * 13);
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
apply_display_info(&with_t, true, 1, &mut back);
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
assert_eq!(back[1].spot, s.spot);
// Truncated data is ignored rather than misread.
let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))];
apply_display_info(&bytes[..10], true, &mut c);
apply_display_info(&bytes[..10], true, 0, &mut c);
assert_eq!(c[0].opacity, 0.5);
}
@@ -117,5 +166,13 @@ mod tests {
assert_eq!(g.to_rgb(), [0.0; 3]);
let lab = decode_color(SPACE_LAB, [10000, 0, 0, 0]).to_rgb();
assert!(lab.iter().all(|v| *v > 0.95), "{lab:?}");
// HSB: hue 120 degrees (65535 / 3), full saturation and brightness is pure green.
let g = decode_color(SPACE_HSB, [21845, 65535, 65535, 0]).to_rgb();
assert!((g[0]).abs() < 1e-3 && (g[1] - 1.0).abs() < 1e-3 && g[2].abs() < 1e-3, "{g:?}");
let half = decode_color(SPACE_HSB, [0, 0, 32768, 0]).to_rgb();
assert!(half.iter().all(|v| (v - 0.5).abs() < 1e-3), "{half:?}");
// Unknown spaces (book colours) are not guessed.
assert_eq!(decode_known_color(3, [1, 2, 3, 4]), None);
assert_eq!(decode_known_color(SPACE_RGB, [65535, 0, 0, 0]), Some(Color::rgb(1.0, 0.0, 0.0)));
}
}
+3
View File
@@ -376,6 +376,9 @@ mod tests {
});
d.last_applied_comp = Some(7);
let res = write_comps_resource(&d).unwrap();
// `list` is a length-4 string id, as Photoshop writes it, never a code.
assert!(res.windows(8).any(|w| w == b"\0\0\0\x04list"));
assert!(!res.windows(8).any(|w| w == b"\0\0\0\0list"));
let cm = write_cmls(&d.layer_comps, None, d.layer(lid).unwrap(), 3).unwrap();
// Attach as Photoshop would: shmd with cmls plus another item.
let mut raw = vec![(*b"shmd", write_shmd(&[MetadataItem::new(*b"cust", vec![1, 2])]))];
+3 -3
View File
@@ -48,9 +48,9 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St
doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) });
}
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, true, 0, &mut doc.channels);
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels);
}
}
@@ -102,7 +102,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect();
resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&names)));
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false)));
}
if let Some(x) = &doc.metadata.xmp {
resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec()));
+41 -2
View File
@@ -529,6 +529,12 @@ impl Ex {
let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0;
!legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a
});
// A Gradient Map whose stops are unchanged keeps the file's own block (stops in
// the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither
// updated in place.
let keep = keep
|| (matches!(a, photocraft_doc::Adjustment::GradientMap { .. })
&& raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels)));
if !keep {
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd");
let w = adjust_map::write(a);
@@ -1435,10 +1441,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
resources.push(ImageResource::new(1032, guides_resource(doc)));
}
if !extra.is_empty() {
let names: Vec<&str> = extra.iter().map(|a| a.name.as_str()).collect();
// One entry per extra channel, the merged transparency included (as in every file
// Photoshop opens; one short, Photoshop refuses the file with "unexpected end-of-file").
let names: Vec<&str> = has_alpha.then_some("Transparency").into_iter().chain(extra.iter().map(|a| a.name.as_str())).collect();
resources.push(ImageResource::new(1006, pascal_names_resource(&names)));
resources.push(ImageResource::new(1045, unicode_names_resource(&names)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha)));
}
if let Some(id) = quick_mask_id {
let mut data = id.to_be_bytes().to_vec();
@@ -1717,4 +1725,35 @@ mod tests {
assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd);
assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb);
}
/// A layered document with transparency in the merged image and an alpha channel: the name
/// and DisplayInfo resources hold one entry per extra channel, the merged transparency first,
/// as in every file Photoshop opens (one short, Photoshop 27.11 refused the file with "Could
/// not import the clipboard because an unexpected end-of-file was encountered"). The alpha
/// channel keeps its own name and options on the way back.
#[test]
fn alpha_names_count_the_merged_transparency() {
let mut doc = document(8, 6);
let mut layer = Layer::raster("Layer 1", doc.pixel_format());
layer.surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]);
doc.layers.push(layer);
let mut a = photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(PixelFormat::GRAY8));
a.opacity = 0.5;
doc.channels.push(a);
let file = document_to_psd(&doc);
let header_extra = usize::from(file.header.channels) - 3;
let names = |id| file.resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap();
let pascal = names(1006);
let (mut count, mut i) = (0, 0);
while i < pascal.len() {
count += 1;
i += 1 + usize::from(pascal[i]);
}
assert_eq!((header_extra, count), (2, 2), "transparency + Alpha 1");
assert_eq!(names(crate::channel_map::DISPLAY_INFO).len(), 4 + 2 * 13);
let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document;
assert_eq!(back.channels.len(), 1);
assert_eq!(back.channels[0].name, "Alpha 1");
assert!((back.channels[0].opacity - 0.5).abs() < 1e-6);
}
}
+19 -3
View File
@@ -702,7 +702,20 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
// Capped like `build` (#2940): a group past the document's nesting cap keeps no children,
// so no file can make this tree, or anything walking or dropping it, recurse deeper.
let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH);
doc.layers = cx.build(&tree, 0);
// Colours stored in the document's CMYK space (Gradient Map stops…) convert through
// its own profile, as the compositor and the PSD writer read them.
let space = photocraft_compose::cmyk_space(&doc);
doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0));
// Photoshop's Background: the bottom record, a transparency-protected pixel layer stored
// without a transparency channel (-1). It is also position-locked, whatever its
// (localized) name and whether or not the file carries an `lspf` block.
if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut())
&& rec.flags.transparency_protected()
&& !rec.channels.iter().any(|c| c.id == -1)
&& matches!(bottom.content, LayerContent::Raster(_))
{
bottom.locks.position = true;
}
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed)
@@ -808,6 +821,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
let first = cc + usize::from(file.merged_has_alpha());
let plane = h.row_bytes() * hh;
let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default();
// Photoshop lists every extra channel, the merged transparency first when there is one.
let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc));
let names = names.get(skip..).unwrap_or_default();
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break };
let mut s = Surface::new(cx.mask_fmt);
@@ -818,9 +834,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
}
// Channel options (overlay colour, opacity, colour indicates) and spot inks.
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels);
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels);
}
// Saved in Quick Mask mode: that channel becomes the document's Quick Mask again.
if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO)
+22
View File
@@ -817,6 +817,28 @@ pub fn uuid_from(seed: &[u8]) -> String {
mod tests {
use super::*;
fn count(hay: &[u8], needle: &[u8]) -> usize {
hay.windows(needle.len()).filter(|w| *w == needle).count()
}
/// A freshly built placed layer writes `warp` (key and class) as a length-4 string, as
/// Photoshop does; the code form is a different key to Photoshop.
#[test]
fn fresh_placed_warp_is_a_string_id() {
let spec =
PlacedSpec { idnt: "id", placed: "id", transform: Affine::IDENTITY, perspective: None, size: (10.0, 10.0), dpi: 72.0, warp: None, filter_fx: None };
let mut warnings = Vec::new();
let sold = sold_bytes(None, &spec, &mut warnings);
let plld = plld_bytes(&spec, &mut warnings);
// `SoLd`: the `warp` key and the warp descriptor's class; `PlLd`: the descriptor's class.
for (bytes, n) in [(&sold, 2), (&plld, 1)] {
assert_eq!(count(bytes, b"\0\0\0\x04warp"), n);
assert_eq!(count(bytes, b"\0\0\0\0warp"), 0);
}
let d = sold_descriptor(&sold).unwrap();
assert!(matches!(d.get("warp"), Some(Value::Descriptor(w)) if w.class_id == Id::Str(b"warp".to_vec())));
}
fn sf(command: &str, params: J) -> SmartFilter {
SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true }
}
+100
View File
@@ -179,3 +179,103 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() {
}
}
}
/// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`.
fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
let mut v = vec![0, 1, 0, 0];
v.extend_from_slice(&1u32.to_be_bytes()); // name: just the terminator
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&2u16.to_be_bytes());
for (t, (space, c)) in [(0u32, a), (4096, b)] {
v.extend_from_slice(&t.to_be_bytes());
v.extend_from_slice(&50u32.to_be_bytes());
v.extend_from_slice(&space.to_be_bytes());
for x in c {
v.extend_from_slice(&x.to_be_bytes());
}
v.extend_from_slice(&[0, 0]);
}
v.extend_from_slice(&2u16.to_be_bytes());
for t in [0u32, 4096] {
v.extend_from_slice(&t.to_be_bytes());
v.extend_from_slice(&50u32.to_be_bytes());
v.extend_from_slice(&255u16.to_be_bytes());
}
v.extend_from_slice(&2u16.to_be_bytes()); // expansion count
v.extend_from_slice(&0u16.to_be_bytes()); // smoothness
v
}
fn saved_grdm(bytes: &[u8]) -> Vec<u8> {
let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap();
f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block")
}
#[test]
fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() {
use photocraft_cms::{Intent, Transform, synth};
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
let profile = Profile::parse(&profile.to_bytes()).unwrap();
let to_srgb = Transform::new(&profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
// First stop 60 % cyan + 40 % black (stored inverted), second paper white.
let cmyk_stop = (2u16, [65535 - 39321, 65535, 65535, 65535 - 26214]);
let paper = (2u16, [65535; 4]);
let lab_dark = (7u16, [2000, 0, 0, 0]);
let lab_red = (7u16, [5000, 6000u16, 2000, 0]);
let gray_black = (8u16, [10000, 0, 0, 0]);
let gray_white = (8u16, [0, 0, 0, 0]);
for depth in SampleType::ALL {
for (mode, stops) in [(ColorMode::Cmyk, (cmyk_stop, paper)), (ColorMode::Lab, (lab_dark, lab_red)), (ColorMode::Grayscale, (gray_white, gray_black))] {
let block = grdm_block(stops.0, stops.1);
let mut doc = Document::new("map", photocraft_geom::Size::new(4, 2), mode, depth);
if mode == ColorMode::Cmyk {
doc.icc_profile = Some(profile.to_bytes());
}
let fmt = doc.pixel_format();
doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false));
doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() })));
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
let mut back = import("x.psd", &bytes).unwrap().document;
let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else {
panic!("{mode:?} {depth:?}: {:?}", back.layers[1].content);
};
let (first, last) = (got[0].1, got[got.len() - 1].1);
match mode {
ColorMode::Cmyk => {
// Through the document's own profile, not the built-in coated CMYK.
let mut expected = [0.0f32; 3];
to_srgb.eval_fast(&[0.6, 0.0, 0.0, 0.4], &mut expected);
assert!(first.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-4), "{depth:?}: {first:?} vs {expected:?}");
let builtin = photocraft_color::convert::cmyk_to_rgb([0.6, 0.0, 0.0, 0.4]);
assert!(first.iter().zip(builtin).any(|(a, b)| (a - b).abs() > 1e-3), "the document profile is used");
}
ColorMode::Lab => {
assert!(first.iter().all(|v| (v - first[0]).abs() < 1e-3) && first[0] < 0.3, "{first:?}");
assert!(last[0] > last[1] + 0.2, "a = +60 is reddish: {last:?}");
}
// An inverting map, white to black.
_ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])),
}
// The layer now renders: the composite differs from the background alone.
let with_map = photocraft_compose::flatten(&back).px;
let mut hidden = back.clone();
hidden.layers[1].visible = false;
assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}");
// Saving again keeps the block (and so the stops' colour space) byte for byte...
let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
assert_eq!(saved_grdm(&again), block, "{mode:?} {depth:?}");
// ...and so does toggling Reverse and Dither, which only flip their flags.
if let LayerContent::Adjustment(Adjustment::GradientMap { reverse, dither, .. }) = &mut back.layers[1].content {
*reverse = true;
*dither = true;
}
let flipped_file = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
let flipped = saved_grdm(&flipped_file);
assert_eq!(&flipped[2..4], &[1, 1]);
assert_eq!(flipped[4..], block[4..], "{mode:?} {depth:?}: stops keep their colour space");
let reopened = import("x.psd", &flipped_file).unwrap().document;
assert_eq!(reopened.layers[1].content, back.layers[1].content);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//! A PSD's Background is recognised by its structure (the bottom record, transparency-protected,
//! stored without a transparency channel), whatever its name in the Photoshop language that
//! saved the file.
use photocraft_io::psd_to_document;
use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen};
/// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`.
fn file(name: &str, transparency_channel: bool) -> PsdFile {
let mut f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
let bottom = f.layers_mut().first_mut().unwrap();
bottom.name = name.bytes().filter(u8::is_ascii).collect();
bottom.blocks.retain(|b| &b.key != b"luni" && &b.key != b"lspf");
bottom.blocks.push(TaggedBlock::unicode_name(name));
bottom.flags.set_transparency_protected(true);
if !transparency_channel {
bottom.channels.retain(|c| c.id != -1);
}
f
}
#[test]
fn a_localized_background_is_the_background() {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let (d, _) = psd_to_document(&file(name, false));
let bg = d.background().unwrap_or_else(|| panic!("{name}: no Background"));
assert_eq!(bg.name, name);
assert!(bg.locks.transparency && bg.locks.position, "{name}");
assert_eq!(bg.id, d.layers[0].id);
}
}
#[test]
fn a_protected_layer_with_transparency_is_not_the_background() {
// Lock Transparent Pixels on an ordinary bottom layer: it keeps its transparency channel.
let (d, _) = psd_to_document(&file("Background", true));
assert!(d.layers[0].locks.transparency);
assert!(d.background().is_none());
}
+85
View File
@@ -857,6 +857,91 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
assert!(r.tail_preview().is_none());
}
#[test]
fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() {
// Symmetric painting previews the stroke and its mirrors with `composite_union_live`, which
// redraws only changed tiles; after every chunk the target must be exactly what finishing
// every pass and merging them with `composite_union_many` gives (the commit takes these pixels).
let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() };
let mut brushes = vec![base.clone()];
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() });
let mut b = base.clone();
b.dual_brush.enabled = true;
b.dual_brush.size = 6.0;
b.dual_brush.scatter = 1.5;
brushes.push(b);
let mut b = base.clone();
b.color_dynamics.enabled = true;
b.color_dynamics.hue_jitter = 0.5;
brushes.push(b);
brushes.push(BrushSettings { wet_edges: true, ..base.clone() });
brushes.push(BrushSettings { mode: BlendMode::Dissolve, ..base.clone() });
brushes.push(BrushSettings { erase: true, ..base.clone() });
let mut b = base.clone();
b.texture.enabled = true;
b.texture.each_tip = false;
brushes.push(b);
let stroke: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(30.0 + i as f64 * 3.7, 40.0 + 25.0 * (i as f64 / 5.0).sin(), 1.0)).collect();
// Mirrors about x = 100 and y = 75; the first point sits near the vertical axis so passes overlap.
let mirror = |fx: bool, fy: bool| -> Vec<StrokePoint> {
stroke.iter().map(|p| StrokePoint::new(if fx { 200.0 - p.x } else { p.x }, if fy { 150.0 - p.y } else { p.y }, p.pressure)).collect()
};
let paths = [stroke.clone(), mirror(true, false), mirror(false, true), mirror(true, true)];
let area = Rect::new(-20, -20, 240, 190);
let mut sel = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
let sel_rect = Rect::new(0, 0, 160, 120);
sel.write_region(sel_rect, &(0..160 * 120).map(|i| ((i % 160) as f32 / 120.0).min(1.0)).collect::<Vec<_>>());
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
let mut pre = Surface::new(fmt);
let fill = Rect::new(10, 10, 190, 140);
let n = fmt.channels();
let px: Vec<f32> =
(0..fill.width() * fill.height()).flat_map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.8].into_iter().take(n)).collect();
pre.write_region(fill, &px);
for (bi, b) in brushes.iter().enumerate() {
// (passes included at each chunk, selection): two passes from the start; four, of
// which two join part-way.
for (four, selection) in [(false, None), (true, None), (true, Some(&sel))] {
let mut renderers: Vec<StrokeRenderer> = (0..4).map(|_| StrokeRenderer::new(b, Some(fmt), 1.0)).collect();
let mut live = crate::render::UnionLive::default();
let mut target = pre.clone();
let (mut at, mut chunk, mut merged) = (0, 1, 0);
while at < stroke.len() {
let end = (at + chunk).min(stroke.len());
for (r, path) in renderers.iter_mut().zip(&paths) {
r.push(&path[at..end]);
}
let count = if four && at >= 12 { 4 } else { 2 };
let full = merged != count;
merged = count;
let mut passes: Vec<&mut StrokeRenderer> = renderers.iter_mut().take(count).collect();
StrokeRenderer::composite_union_live(&mut passes, &mut live, full, &pre, &mut target, selection, false);
let finished: Vec<StrokeRenderer> = renderers
.iter()
.take(count)
.map(|r| {
let mut r = r.clone();
r.finish();
r
})
.collect();
let mut expected = pre.clone();
finished[0].composite_union_many(&finished[1..], &pre, &mut expected, selection, false);
assert!(
target.read_region(area) == expected.read_region(area),
"{sample:?}, brush {bi}, four {four}, selection {}, points ..{end}",
selection.is_some()
);
assert_eq!(live.bounds(), finished.iter().fold(Rect::EMPTY, |r, f| r.union(&f.bounds())));
at = end;
chunk = chunk % 3 + 1;
}
}
}
}
}
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
+1 -1
View File
@@ -37,7 +37,7 @@ pub use brush::{
SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer,
};
pub use mixer::MixerSettings;
pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke};
pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke};
pub use tile::{GrayTile, StoredTile};
/// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation.
+121
View File
@@ -491,6 +491,13 @@ impl CoverageMap {
v
}
/// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the
/// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own.
fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self {
let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone()));
Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() }
}
/// Union two passes of the same brush stroke without applying opacity twice where they
/// overlap. The stronger coverage wins, including its per-dab colour when present.
fn union_max(&mut self, other: &Self) {
@@ -527,6 +534,21 @@ impl CoverageMap {
// Stroke renderer
// ---------------------------------------------------------------------------
/// What [`StrokeRenderer::composite_union_live`] drew last: the coverage tiles of the passes'
/// smoothing tails (redrawn without them next time) and the merged bounds.
#[derive(Clone, Debug, Default)]
pub struct UnionLive {
tail_keys: HashSet<(i32, i32)>,
bounds: Rect,
}
impl UnionLive {
/// The merged bounds of the finished passes, as last drawn.
pub fn bounds(&self) -> Rect {
self.bounds
}
}
/// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels.
#[derive(Clone, Debug)]
pub struct StrokeRenderer {
@@ -570,6 +592,105 @@ impl StrokeRenderer {
}
merged.composite(pre, target, selection, lock_transparency, true)
}
/// Coverage tiles (stroke and Dual Brush) changed since the last call or composite, into
/// `keys`; forgets them.
fn drain_dirty_into(&mut self, keys: &mut HashSet<(i32, i32)>) {
keys.extend(self.cov.dirty.drain());
if let Some(d) = self.dual.as_mut() {
keys.extend(d.dirty.drain());
}
}
/// Every coverage tile (stroke and Dual Brush), into `keys`.
fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) {
keys.extend(self.cov.tiles.keys());
if let Some(d) = &self.dual {
keys.extend(d.tiles.keys());
}
}
/// Live preview of a stroke painted as several passes (the stroke, then its symmetry
/// mirrors): leaves `target` as finishing every pass now and compositing them with
/// [`composite_union_many`](Self::composite_union_many) over `pre` would, but redraws only the
/// coverage tiles that changed since the previous call (new dabs, and the smoothing tail now
/// and before). `full` redraws every tile: on the first call, or when a pass joins. Returns
/// the damaged rectangle.
pub fn composite_union_live(
passes: &mut [&mut Self],
live: &mut UnionLive,
full: bool,
pre: &Surface,
target: &mut Surface,
selection: Option<&Surface>,
lock_transparency: bool,
) -> Rect {
let mut keys = std::mem::take(&mut live.tail_keys);
for p in passes.iter_mut() {
p.drain_dirty_into(&mut keys);
if full {
p.keys_into(&mut keys);
}
}
let tails: Vec<_> = passes.iter().map(|p| p.tail_preview()).collect();
let mut tail_keys = HashSet::new();
for t in tails.iter().flatten() {
t.keys_into(&mut tail_keys);
}
keys.extend(&tail_keys);
let mut merged: Option<Self> = None;
for (p, t) in passes.iter().zip(&tails) {
let cov = p.cov.finished_subset(t.as_ref().map(|t| &t.cov), &keys);
let dual = p.dual.as_ref().map(|d| d.finished_subset(t.as_ref().and_then(|t| t.dual.as_ref()), &keys));
match merged.as_mut() {
None => {
merged = Some(Self {
ctx: p.ctx.clone(),
generator: p.generator.clone(),
cov,
dual,
fmt: p.fmt,
per_dab_color: p.per_dab_color,
dabs_done: p.dabs_done,
scratch: Vec::new(),
depth_scratch: Vec::new(),
dab_buf: Vec::new(),
dual_buf: Vec::new(),
all_dabs: None,
})
}
Some(m) => {
m.cov.union_max(&cov);
if let (Some(to), Some(from)) = (&mut m.dual, &dual) {
to.union_max(from);
}
}
}
}
let Some(mut merged) = merged else { return Rect::EMPTY };
let (before, after) = (live.bounds, merged.bounds());
// Back to the pre-stroke pixels where the previous call drew and nothing covers now.
let mut keys: Vec<_> = keys.into_iter().collect();
keys.sort_unstable();
let mut dmg = Rect::EMPTY;
let mut region = Vec::new();
for &(tx, ty) in &keys {
let tile = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE);
let old = tile.intersect(&before);
if !old.is_empty() {
pre.read_region_into(old, &mut region);
target.write_region(old, &region);
dmg = dmg.union(&old);
}
if merged.cov.tiles.contains_key(&(tx, ty)) {
dmg = dmg.union(&tile.intersect(&after));
}
}
merged.composite(pre, target, selection, lock_transparency, true);
live.tail_keys = tail_keys;
live.bounds = after;
dmg
}
/// `fmt` = target pixel format (needed for per-dab colour); `zoom` for smoothing.
pub fn new(brush: &BrushSettings, fmt: Option<PixelFormat>, zoom: f32) -> Self {
let per_dab_color = brush.color_dynamics.enabled && !brush.erase && fmt.is_some();
+56
View File
@@ -42,3 +42,59 @@ path = "fuzz_targets/image_source_data.rs"
test = false
doc = false
bench = false
[[bin]]
name = "grd"
path = "fuzz_targets/grd.rs"
test = false
doc = false
bench = false
[[bin]]
name = "ase"
path = "fuzz_targets/ase.rs"
test = false
doc = false
bench = false
[[bin]]
name = "aco"
path = "fuzz_targets/aco.rs"
test = false
doc = false
bench = false
[[bin]]
name = "patterns"
path = "fuzz_targets/patterns.rs"
test = false
doc = false
bench = false
[[bin]]
name = "filter_effects"
path = "fuzz_targets/filter_effects.rs"
test = false
doc = false
bench = false
[[bin]]
name = "metadata"
path = "fuzz_targets/metadata.rs"
test = false
doc = false
bench = false
[[bin]]
name = "tagged"
path = "fuzz_targets/tagged.rs"
test = false
doc = false
bench = false
[[bin]]
name = "resources"
path = "fuzz_targets/resources.rs"
test = false
doc = false
bench = false
+21
View File
@@ -0,0 +1,21 @@
//! Fuzz the `.aco` Photoshop Color Swatches parser: any input must return `Ok` or `Err`, never
//! panic; anything that parses must round-trip through `write` and re-parse with the same swatch
//! count, and no name may exceed the name-unit cap.
//!
//! `MAX_SWATCHES` is `u16::MAX` and the on-disk count is a `u16`, so the count is unfalsifiable by
//! construction; the assertion is kept as documentation of that invariant, not as a check that can
//! fail. The name cap, which is reachable, is the one that bites.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::aco;
fuzz_target!(|data: &[u8]| {
let Ok(f) = aco::parse(data) else { return };
assert!(f.swatches.len() <= aco::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatches.len());
for s in &f.swatches {
assert!(s.name.encode_utf16().count() <= aco::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS");
}
let out = aco::write(&f.swatches).expect("a parsed .aco must be writable");
let back = aco::parse(&out).expect("a written .aco must re-parse");
assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip");
assert_eq!(aco::write(&back.swatches).expect("rewritable"), out, "a second round trip is not byte-stable");
});
+39
View File
@@ -0,0 +1,39 @@
//! Fuzz the `.ase` Adobe Swatch Exchange parser: any input must return `Ok` or `Err`, never panic;
//! anything that parses must hold only finite colour components, stay within the swatch cap and
//! round-trip through `write` and re-parse with the same number of entries.
//!
//! A colour outside any group is a legitimate `AseEntry::Swatch`, and a group left open at the
//! end is closed by the parser, so entry shape is checked only through the round trip below.
//! The cap assertion is an invariant, not a fast check: no 4 KB input can carry 100 001 blocks.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::ase::{self, AseColor, AseEntry};
fuzz_target!(|data: &[u8]| {
let Ok(f) = ase::parse(data) else { return };
assert!(f.swatch_count() <= ase::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatch_count());
for e in &f.entries {
let swatches = match e {
AseEntry::Swatch(s) => std::slice::from_ref(s),
AseEntry::Group { swatches, .. } => swatches.as_slice(),
};
for s in swatches {
assert!(s.name.encode_utf16().count() <= ase::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS");
// Rgb/Lab carry three components, Cmyk four, Gray one.
let check = |c: &[f32]| {
for v in c {
assert!(v.is_finite(), "colour component {v} is not finite");
}
};
match &s.color {
AseColor::Rgb(v) | AseColor::Lab(v) => check(v),
AseColor::Cmyk(v) => check(v),
AseColor::Gray(v) => check(std::slice::from_ref(v)),
AseColor::Other { .. } => {}
}
}
}
let out = ase::write(&f).expect("a parsed .ase must be writable");
let back = ase::parse(&out).expect("a written .ase must re-parse");
assert_eq!(back.swatch_count(), f.swatch_count(), "swatch count changed across a round trip");
assert_eq!(ase::write(&back).expect("rewritable"), out, "a second round trip is not byte-stable");
});
@@ -0,0 +1,22 @@
//! Fuzz the `FEid` / `FXid` smart-filter cache parser: any input must return `Ok` or `Err`, never
//! panic. Decoding every cached plane and the filter mask must also stay panic-free (a size the
//! data cannot hold is refused before allocation, for RLE and zlib alike), and a parsed block must
//! re-parse to the same value and re-serialise byte for byte.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::filter_effects::FilterEffects;
fuzz_target!(|data: &[u8]| {
let Ok(fx) = FilterEffects::parse(data) else { return };
for it in &fx.items {
let _ = it.decoded_mask();
if let (Ok(depth), Ok((w, h))) = (u16::try_from(it.depth), it.rect.size()) {
for p in it.slots.iter().flatten() {
let _ = p.decode(w, h, depth);
}
}
}
let out = fx.to_bytes();
let back = FilterEffects::parse(&out).expect("a written FEid block must re-parse");
assert_eq!(back, fx, "a written FEid block does not re-parse to the same value");
assert_eq!(back.to_bytes(), out, "a second round trip is not byte-stable");
});
+52
View File
@@ -0,0 +1,52 @@
//! Fuzz the `.grd` gradient parser: any input must return `Ok` or `Err`, never panic, and every
//! number it hands back must be finite and in range.
//!
//! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len`
//! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with
//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::grd::{self, GrdColor};
fuzz_target!(|data: &[u8]| {
let Ok(g) = grd::parse(data) else { return };
assert!(g.len() <= grd::MAX_GRADIENTS, "{} gradients exceeds MAX_GRADIENTS", g.len());
for grad in &g {
assert!(grad.stops.len() <= grd::MAX_STOPS, "{} stops exceeds MAX_STOPS", grad.stops.len());
assert!(grad.opacity.len() <= grd::MAX_STOPS, "{} transparency stops exceeds MAX_STOPS", grad.opacity.len());
for &(l, o, m) in &grad.opacity {
assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range");
assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1");
assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range");
}
for s in &grad.stops {
assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location);
assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint);
// Each model has its own documented range, not just "finite": the parser clamps RGB,
// CMYK and Gray to 0..=1, Hue to 0..=360, L* to 0..=100 and a*/b* to the CIELAB gamut.
let unit = |c: &[f32]| {
for v in c {
assert!(v.is_finite() && (0.0..=1.0).contains(v), "colour component {v} outside 0..=1");
}
};
match s.color {
GrdColor::Rgb(v) => unit(&v),
GrdColor::Cmyk(v) => unit(&v),
GrdColor::Hsb([h, sat, br]) => {
assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} outside 0..=360");
unit(&[sat, br]);
}
GrdColor::Lab([l, a, b]) => {
assert!(l.is_finite() && (0.0..=100.0).contains(&l), "L* {l} outside 0..=100");
assert!(a.is_finite() && (-128.0..=127.0).contains(&a), "a* {a} outside the CIELAB gamut");
assert!(b.is_finite() && (-128.0..=127.0).contains(&b), "b* {b} outside the CIELAB gamut");
}
GrdColor::Gray(v) => unit(&[v]),
GrdColor::Foreground | GrdColor::Background => {}
}
}
}
let out = grd::write(&g);
let back = grd::parse(&out).expect("a written .grd must re-parse");
assert_eq!(back.len(), g.len(), "gradient count changed across a round trip");
assert_eq!(grd::write(&back), out, "a second round trip is not byte-stable");
});
+17
View File
@@ -0,0 +1,17 @@
//! Fuzz the `shmd` (metadata setting) parser: any input must return `Ok` or `Err`, never panic.
//!
//! The `copy` field is a bool, so a non-canonical copy byte (anything but 0 or 1) normalises to 1
//! on write; exact byte equality therefore cannot hold for arbitrary input, only for canonical
//! files. The invariant that does hold, and that catches reader/writer drift, is that a
//! parsed-then-written block re-parses to the *same items* (semantic equality, not just byte
//! stability -- byte stability alone would also pass if the writer silently dropped a field).
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::metadata::{parse_shmd, write_shmd};
fuzz_target!(|data: &[u8]| {
let Ok(items) = parse_shmd(data) else { return };
let out = write_shmd(&items);
let back = parse_shmd(&out).expect("a written shmd block must re-parse");
assert_eq!(back, items, "a written shmd block does not re-parse to the same items");
assert_eq!(write_shmd(&back), out, "shmd is not byte-stable across a round trip");
});
+20
View File
@@ -0,0 +1,20 @@
//! Fuzz the pattern parsers: the `Patt` / `Pat2` / `Pat3` tagged blocks and standalone `.pat`
//! files. Any input must return `Ok` or `Err`, never panic. The channel offsets and padding a
//! hostile file can carry are wire artifacts, not fields of `PsdPattern`, so a parsed pattern must
//! survive a write/parse round trip unchanged -- a stronger invariant than byte stability, which
//! would also hold if the writer silently normalised a field away.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block};
fuzz_target!(|data: &[u8]| {
if let Ok(ps) = parse_pattern_block(data) {
let out = write_pattern_block(&ps).expect("a parsed pattern block must be writable");
let back = parse_pattern_block(&out).expect("a written pattern block must re-parse");
assert_eq!(back, ps, "a pattern block does not survive a write/parse round trip");
}
if let Ok(ps) = parse_pat_file(data) {
let out = write_pat_file(&ps).expect("a parsed .pat must be writable");
let back = parse_pat_file(&out).expect("a written .pat must re-parse");
assert_eq!(back, ps, "a .pat file does not survive a write/parse round trip");
}
});
+24
View File
@@ -0,0 +1,24 @@
//! Fuzz the typed image-resource views: `ImageResource::parsed` for an arbitrary resource id, plus
//! the standalone `ResolutionInfo` and `VersionInfo` parsers. All must return `Ok` / `Err` / `None`
//! (and `xmp_str` an `Option`), never panic. A parsed `ResolutionInfo` (16 fixed bytes) must be
//! stable across a byte round trip. The first two bytes pick the id so every typed branch is
//! reachable; the rest is the resource data.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::resources::{ImageResource, ResolutionInfo, VersionInfo};
fuzz_target!(|data: &[u8]| {
let (id, body) = match data.split_first_chunk::<2>() {
Some((p, rest)) => (u16::from_be_bytes(*p), rest.to_vec()),
None => (0u16, data.to_vec()),
};
let r = ImageResource::new(id, body.clone());
let _ = r.parsed();
let _ = r.xmp_str();
if let Ok(ri) = ResolutionInfo::from_bytes(&body) {
let round = ResolutionInfo::from_bytes(&ri.to_bytes()).expect("resolution info re-parses");
assert_eq!(round, ri, "ResolutionInfo is not stable across a round trip");
}
if let Ok(vi) = VersionInfo::from_bytes(&body) {
let _ = vi.to_bytes();
}
});
+28
View File
@@ -0,0 +1,28 @@
//! Fuzz the per-block views of a tagged block: `parsed` (typed, well-known keys) and
//! `check_structure` (strict re-parse of blocks that carry their own inner sizes: placed layers,
//! linked files, object effects, filter caches). Both must return `Ok` / `Err` / `None`, never
//! panic, for arbitrary data.
//!
//! The first byte selects the key from a table of every key with a typed or strict branch, so the
//! fuzzer reaches each branch deterministically instead of having to guess a 4-byte key from raw
//! bytes (which it would almost never do); the rest is the block data.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::tagged::TaggedBlock;
const KEYS: [&[u8; 4]; 20] = [
// typed (parsed)
b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr", b"iOpa", b"shmd",
// strict (check_structure)
b"PlLd", b"SoLd", b"lnk2", b"lfx2", b"FEid",
];
fuzz_target!(|data: &[u8]| {
let (key, body) = match data.split_first() {
Some((&sel, rest)) => (*KEYS[sel as usize % KEYS.len()], rest.to_vec()),
None => (*b"luni", Vec::new()),
};
let b = TaggedBlock::new(key, body);
let _ = b.parsed();
let _ = b.check_structure();
});
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn write(swatches: &[AcoSwatch]) -> Result<Vec<u8>> {
out.put_u16(x);
}
if version == 2 {
let units: Vec<u16> = s.name.encode_utf16().take(MAX_NAME_UNITS).collect();
let units = crate::io::utf16_units(&s.name, MAX_NAME_UNITS);
// The length counts the terminating null.
out.put_u32(units.len() as u32 + 1);
for u in units {
+4 -1
View File
@@ -24,6 +24,9 @@ pub const SIGNATURE: &[u8; 4] = b"ASEF";
pub const MAX_SWATCHES: usize = 100_000;
/// Longest name read or written, in UTF-16 code units.
pub const MAX_NAME_UNITS: usize = 1024;
// `put_name` writes the name length as `units.len() as u16 + 1`, so the cap must leave room in a
// u16; this guards that invariant if the constant is ever raised.
const _: () = assert!(MAX_NAME_UNITS < u16::MAX as usize);
const GROUP_START: u16 = 0xC001;
const GROUP_END: u16 = 0xC002;
@@ -226,7 +229,7 @@ pub fn parse(data: &[u8]) -> Result<AseFile> {
}
fn put_name(out: &mut Vec<u8>, name: &str) {
let units: Vec<u16> = name.encode_utf16().take(MAX_NAME_UNITS).collect();
let units = crate::io::utf16_units(name, MAX_NAME_UNITS);
out.put_u16(units.len() as u16 + 1);
for u in units {
out.put_u16(u);
+39 -2
View File
@@ -44,6 +44,10 @@ impl UnicodeString {
}
}
/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes
/// (`00000004 'warp'`, not `00000000 'warp'`).
pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"];
/// A class / key / type identifier: a length-prefixed string, where a stored
/// length of 0 means a 4-character code follows.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -57,10 +61,15 @@ pub enum Id {
impl Id {
/// Convenience constructor: 4-byte strings become [`Id::Code`], others
/// [`Id::Str`].
/// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings
/// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the
/// string and the code as different keys and doesn't recognise the code form.
pub fn new(s: &str) -> Self {
let b = s.as_bytes();
if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) }
match b {
[a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]),
_ => Id::Str(b.to_vec()),
}
}
/// The identifier's bytes.
pub fn as_bytes(&self) -> &[u8] {
@@ -609,6 +618,34 @@ mod tests {
assert_eq!(p.to_bytes(), b);
}
/// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both
/// as keys and as class ids; other four-character names stay codes. Reading accepts both
/// forms, and a code read from a file is written back as a code.
#[test]
fn photoshop_string_ids_are_written_as_strings() {
let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat();
let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat();
for name in STRING_IDS {
assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec()));
let d = Descriptor::new(name).with(name, Value::Integer(1));
let b = d.to_bytes();
// Name (empty unicode string), then the class id; then the item count and the key.
assert_eq!(&b[4..12], &str_id(name)[..], "class {name}");
assert_eq!(&b[16..24], &str_id(name)[..], "key {name}");
let back = Descriptor::from_bytes(&b).unwrap();
assert_eq!(back, d);
assert!(back.class_id.is(name) && back.get(name).is_some());
// A file that wrote the code form keeps it, and lookups still find the key.
let mut coded = b.clone();
coded.splice(16..24, code_id(name));
let back = Descriptor::from_bytes(&coded).unwrap();
assert_eq!(back.get(name), Some(&Value::Integer(1)));
assert_eq!(back.to_bytes(), coded);
}
assert_eq!(Id::new("view"), Id::Code(*b"view"));
assert_eq!(Id::new("Txt "), Id::Code(*b"Txt "));
}
#[test]
fn versioned_roundtrip() {
let v = VersionedDescriptor::new(sample());
+45 -9
View File
@@ -58,6 +58,9 @@ pub struct GrdGradient {
pub opacity: Vec<(f32, f32, f32)>,
}
/// The raw number behind a key. It is deliberately *not* filtered: every caller passes the result
/// through `finite`, so filtering here as well would give two guards with two different answers to
/// the same input, and mutation testing showed the second one was unreachable.
fn num(d: &Descriptor, k: &str) -> Option<f64> {
match d.get(k)? {
Value::Double(v) => Some(*v),
@@ -66,11 +69,36 @@ fn num(d: &Descriptor, k: &str) -> Option<f64> {
Value::LargeInteger(v) => Some(*v as f64),
_ => None,
}
.filter(|v| v.is_finite())
}
/// The single guard every number passes through on its way to an `f32`. Only a NaN is truly
/// without a value, so only a NaN returns `None`; the caller then falls back to the default its
/// own field documents (opacity opaque, a midpoint centred, a channel zero).
///
/// Everything else — including `±inf` and a value finite as `f64` but larger than `f32::MAX`
/// (`1e300`, which would otherwise overflow the cast to an infinity) — is *saturated* to the
/// representable range before the cast. That keeps the mapping monotonic: a bigger input never
/// yields a smaller output, so "too large" lands at the top of the range rather than snapping to a
/// field default and inverting the result.
fn finite(v: f64) -> Option<f32> {
if v.is_nan() {
return None;
}
Some(v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32)
}
fn f(d: &Descriptor, k: &str) -> f32 {
num(d, k).unwrap_or(0.0) as f32
finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0)
}
fn clamp01(v: f32) -> f32 {
v.clamp(0.0, 1.0)
}
/// A hue in degrees. An in-range value (0..=360, including exactly 360) is kept verbatim; only an
/// out-of-range value wraps with `rem_euclid`, so 370 -> 10 and -10 -> 350. Wrapping every value
/// would alter a legal 360 to 0 (and f32 round-off on a tiny negative can itself land on 360),
/// changing the colour at the boundary. The input is already finite, so the result stays finite.
fn hue(h: f32) -> f32 {
if (0.0..=360.0).contains(&h) { h } else { h.rem_euclid(360.0) }
}
fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> {
match d.get(k)? {
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
@@ -90,11 +118,19 @@ fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool {
fn color(c: &Descriptor) -> Option<GrdColor> {
let id = c.class_id.as_bytes();
Some(match id {
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0]),
b"HSBC" => GrdColor::Hsb([f(c, "H "), f(c, "Strt") / 100.0, f(c, "Brgh") / 100.0]),
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0]),
b"LbCl" => GrdColor::Lab([f(c, "Lmnc"), f(c, "A "), f(c, "B ")]),
b"Grsc" => GrdColor::Gray(f(c, "Gry ") / 100.0),
// Every component documented as 0..=1 is clamped here, like every other range in this
// module: a hostile file can store any integer at all, and dividing by 100 or 255 does not
// bring it back into range on its own.
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0].map(clamp01)),
// Hue is a circular degree value in 0..=360; see `hue`. An in-range value (including
// exactly 360) is kept, only an out-of-range one wraps, so a hostile hue cannot become a
// different, wrong colour at the boundary.
b"HSBC" => GrdColor::Hsb([hue(f(c, "H ")), (f(c, "Strt") / 100.0).clamp(0.0, 1.0), (f(c, "Brgh") / 100.0).clamp(0.0, 1.0)]),
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0].map(clamp01)),
// L* is 0..=100; a*/b* are signed and bounded to the CIELAB gamut (-128..=127) so a hostile
// value cannot hand a downstream colour converter an out-of-gamut number to overflow on.
b"LbCl" => GrdColor::Lab([f(c, "Lmnc").clamp(0.0, 100.0), f(c, "A ").clamp(-128.0, 127.0), f(c, "B ").clamp(-128.0, 127.0)]),
b"Grsc" => GrdColor::Gray(clamp01(f(c, "Gry ") / 100.0)),
_ => return None,
})
}
@@ -103,7 +139,7 @@ fn location(d: &Descriptor) -> f32 {
(f(d, "Lctn") / 4096.0).clamp(0.0, 1.0)
}
fn midpoint(d: &Descriptor) -> f32 {
(num(d, "Mdpn").unwrap_or(50.0) as f32 / 100.0).clamp(0.0, 1.0)
finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0)
}
fn gradient(g: &Descriptor) -> GrdGradient {
@@ -127,7 +163,7 @@ fn gradient(g: &Descriptor) -> GrdGradient {
}
for v in list(g, "Trns").iter().take(MAX_STOPS) {
let Value::Descriptor(s) = v else { continue };
out.opacity.push((location(s), (num(s, "Opct").unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0), midpoint(s)));
out.opacity.push((location(s), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s)));
}
out
}
+17
View File
@@ -2,6 +2,23 @@
use crate::error::{PsdError, Result};
/// A name as UTF-16 code units, capped at `max` **without ever splitting a surrogate pair**.
/// `encode_utf16().take(max)` would cut between the high and low half of a pair above U+FFFF and
/// write ill-formed UTF-16 to disk, so whole scalar values are added until one would not fit.
/// Both the `.aco` and `.ase` writers share this; keeping it in one place is deliberate, so the
/// surrogate fix cannot drift between the two formats.
pub(crate) fn utf16_units(name: &str, max: usize) -> Vec<u16> {
let mut units = Vec::new();
for c in name.chars() {
if units.len() + c.len_utf16() > max {
break;
}
let mut buf = [0u16; 2];
units.extend_from_slice(c.encode_utf16(&mut buf));
}
units
}
/// A cursor over a byte slice. Every read is bounds checked. Integers are big-endian (the PSD
/// byte order) unless the reader was made with [`Reader::new_little`], which only the TIFF
/// byte-order transcoder uses ([`crate::tiff`]).
+399
View File
@@ -148,3 +148,402 @@ proptest! {
prop_assert_eq!(p.to_bytes().unwrap(), b);
}
}
// The three standalone swatch/gradient parsers sit outside `PsdFile::from_bytes`, so their
// round-trip invariants and no-panic guarantee get their own properties here. `write` covers only
// what each format can express, so each property states the invariant that actually holds instead
// of claiming byte equality everywhere.
/// A position or midpoint the writer can actually store: the file keeps 1/4096ths, so generating
/// arbitrary `0.0..=1.0` values would make every property fail on quantisation, not on a bug.
fn unit() -> impl Strategy<Value = f32> {
(0.0f32..=1.0).prop_map(|v| (v * 4096.0).round() / 4096.0)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn grd_roundtrip_keeps_stops_in_range(
stops in proptest::collection::vec((unit(), unit(), any::<u8>()), 0..12),
opacity in proptest::collection::vec((unit(), unit(), unit()), 0..12),
) {
use photocraft_psd::grd::{self, GrdColor, GrdGradient, GrdStop};
let g = GrdGradient {
name: "p".into(),
noise: false,
stops: stops.iter().map(|&(l, m, tone)| GrdStop {
location: l,
midpoint: m,
color: GrdColor::Rgb([tone as f32 / 255.0, 0.0, 0.0]),
}).collect(),
opacity: opacity.clone(),
};
let bytes = grd::write(&[g]);
let parsed = grd::parse(&bytes).unwrap();
prop_assert_eq!(parsed.len(), 1);
let back = &parsed[0];
prop_assert_eq!(back.stops.len(), stops.len());
prop_assert_eq!(back.opacity.len(), opacity.len());
for (a, b) in back.stops.iter().zip(&stops) {
prop_assert!(a.location.is_finite() && (0.0..=1.0).contains(&a.location));
prop_assert!((a.location - b.0).abs() < 1.0 / 4096.0);
prop_assert!(a.midpoint.is_finite() && (0.0..=1.0).contains(&a.midpoint));
prop_assert!((a.midpoint - b.1).abs() < 0.01);
// The colour was written, so it must come back: a parser that zeroed every colour
// would pass a property that only looked at positions.
let GrdColor::Rgb([r, g, bl]) = a.color else {
prop_assert!(false, "colour must survive the round trip");
return Ok(());
};
prop_assert!((r - b.2 as f32 / 255.0).abs() < 1.0 / 255.0, "red channel {r} lost");
prop_assert!(g.is_finite() && g.abs() < 1.0 / 255.0, "green channel {g} corrupted");
prop_assert!(bl.is_finite() && bl.abs() < 1.0 / 255.0, "blue channel {bl} corrupted");
}
for (a, b) in back.opacity.iter().zip(&opacity) {
prop_assert!(a.0.is_finite() && (0.0..=1.0).contains(&a.0));
prop_assert!((0.0..=1.0).contains(&a.1));
prop_assert!(a.2.is_finite() && (0.0..=1.0).contains(&a.2));
prop_assert!((a.0 - b.0).abs() < 1.0 / 4096.0);
prop_assert!((a.1 - b.1).abs() < 0.01);
prop_assert!((a.2 - b.2).abs() < 0.01, "transparency midpoint not preserved");
}
}
#[test]
fn ase_roundtrip_is_byte_stable_after_one_write(
swatches in proptest::collection::vec(("[ -~]{0,20}", any::<u8>()), 0..10),
) {
use photocraft_psd::ase::{self, AseColor, AseEntry, AseFile, AseKind, AseSwatch};
let mk = |(name, tone): &(String, u8)| AseSwatch {
name: name.clone(),
color: AseColor::Rgb([*tone as f32 / 255.0, 0.0, 0.0]),
kind: AseKind::Process,
};
let file = AseFile {
entries: vec![
AseEntry::Group { name: "g".into(), swatches: swatches.iter().take(3).map(mk).collect() },
AseEntry::Group { name: "h".into(), swatches: swatches.iter().skip(3).map(mk).collect() },
],
};
let bytes = ase::write(&file).unwrap();
let back = ase::parse(&bytes).unwrap();
prop_assert_eq!(back.swatch_count(), swatches.len());
prop_assert_eq!(ase::write(&back).unwrap(), bytes, "a second round trip is byte-stable");
}
#[test]
fn aco_roundtrip_is_byte_stable(
swatches in proptest::collection::vec(("[ -~]{0,20}", any::<u8>(), any::<u16>()), 0..10),
) {
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
let s: Vec<AcoSwatch> = swatches.iter().map(|(name, space, v)| AcoSwatch {
name: name.clone(),
color: match space % 3 {
0 => AcoColor::Rgb([*v, 0, 0]),
1 => AcoColor::Gray(*v),
_ => AcoColor::Lab { l: *v, a: -12800, b: 12700 },
},
}).collect();
let bytes = aco::write(&s).unwrap();
let back = aco::parse(&bytes).unwrap();
prop_assert_eq!(&back.swatches, &s);
prop_assert_eq!(aco::write(&back.swatches).unwrap(), bytes, "a second round trip is byte-stable");
}
/// A name longer than the cap is truncated on the way in, so the file stays writable and the
/// bytes settle after one round trip.
#[test]
fn aco_long_names_settle_after_one_truncation(len in 1024usize..4000) {
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
let s = vec![AcoSwatch { name: "x".repeat(len), color: AcoColor::Gray(5000) }];
let once = aco::write(&s).unwrap();
let back = aco::parse(&once).unwrap();
prop_assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS);
prop_assert_eq!(aco::write(&back.swatches).unwrap(), once);
}
/// Pure junk rarely gets past the signature check, so most of each parser is never reached.
/// These damage files that are already valid instead, which is where the interesting inputs
/// live: well-formed structure with one byte corrupted.
#[test]
fn swatch_parsers_never_panic_on_damaged_valid_files(
idx in 0usize..4000,
bit in 0u32..8,
mask in 0u8..=255,
) {
use photocraft_psd::{aco, ase, grd};
let grd_bytes = {
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
write(&[GrdGradient {
name: "d".into(),
noise: false,
stops: vec![GrdStop { location: 0.25, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.5, 0.0]) }],
opacity: vec![(0.5, 1.0, 0.5)],
}])
};
let ase_bytes = {
use photocraft_psd::ase::{write, AseColor, AseEntry, AseFile, AseKind, AseSwatch};
let sw = || AseSwatch { name: "d".into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
write(&AseFile {
entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![sw()] }, AseEntry::Swatch(sw())],
})
.unwrap()
};
let aco_bytes = aco::write(&[aco::AcoSwatch { name: "d".into(), color: aco::AcoColor::Rgb([1, 2, 3]) }]).unwrap();
let damage = |mut b: Vec<u8>| -> Vec<u8> {
if !b.is_empty() {
let i = idx % b.len();
b[i] ^= 1 << bit;
b[i] = b[i].wrapping_add(mask);
}
b
};
let _ = grd::parse(&damage(grd_bytes));
let _ = ase::parse(&damage(ase_bytes));
let _ = aco::parse(&damage(aco_bytes));
}
/// Names that are not ASCII, so the name-unit cap can land between the two halves of a
/// surrogate pair. The cap counts UTF-16 code units, so that split is the boundary case.
#[test]
fn aco_non_ascii_names_are_capped_and_still_round_trip(len in 600usize..2600) {
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
// One leading ASCII character, then two-unit pairs. Without it the cap would always
// fall on a pair boundary and the half-written case this property exists for would
// never happen.
let name = format!("a{}", "\u{1F600}".repeat(len / 2 + 1));
let s = vec![AcoSwatch { name: name.clone(), color: AcoColor::Gray(5000) }];
let once = aco::write(&s).unwrap();
let back = aco::parse(&once).unwrap();
let got = back.swatches[0].name.encode_utf16().count();
prop_assert!(got <= aco::MAX_NAME_UNITS, "name came back as {got} units");
// The cap cuts on a character, so the result is a prefix of the input: whole characters
// only, and never a lone surrogate.
prop_assert!(name.starts_with(&back.swatches[0].name), "the capped name must be a prefix of the input");
prop_assert_eq!(aco::write(&back.swatches).unwrap(), once);
}
/// Arbitrary bytes must never panic in any of the three parsers, whatever the length.
#[test]
fn swatch_parsers_never_panic_on_arbitrary_bytes(data in proptest::collection::vec(any::<u8>(), 0..400)) {
let _ = photocraft_psd::grd::parse(&data);
let _ = photocraft_psd::ase::parse(&data);
let _ = photocraft_psd::aco::parse(&data);
}
}
// The five auxiliary binary parsers (image resources, metadata setting, patterns, filter-effects
// caches and the per-block tagged-block views) parse structures that sit outside the whole-file
// `PsdFile::from_bytes` path when read on their own. Their round-trip invariants and no-panic
// guarantee get their own properties here, so normal CI covers them: the matching fuzz targets run
// only on a manual trigger.
/// A small, valid pattern at a byte-aligned depth, generated the way the unit `pat()` helper builds
/// one: `mode_channels(mode)` colour planes of `width × height × bytes-per-sample`, optional alpha.
fn pattern_strategy() -> impl Strategy<Value = patterns::PsdPattern> {
(prop_oneof![Just(1u32), Just(3u32), Just(4u32)], 1u32..8, 1u32..6, prop_oneof![Just(8u16), Just(16), Just(32)], any::<bool>(), any::<u64>()).prop_map(
|(mode, width, height, depth, alpha, seed)| {
let bpp = usize::from(depth / 8);
let plane = |s: u64| (0..width as usize * height as usize * bpp).map(|i| (i as u64).wrapping_mul(31).wrapping_add(s) as u8).collect::<Vec<u8>>();
let nc = patterns::mode_channels(mode);
patterns::PsdPattern {
mode,
width,
height,
name: "p".into(),
id: "0bd2d3ba-1234-11d4-8f8f-aabbccddeeff".into(),
palette: None,
depth,
channels: (0..nc).map(|c| plane(seed.wrapping_add(c as u64 + 1))).collect(),
alpha: alpha.then(|| plane(seed ^ 0xffff)),
}
},
)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
/// `shmd` is exact: Photoshop keeps each item's even padding inside its stored length, so a
/// built block writes, parses and re-writes byte for byte.
#[test]
fn metadata_shmd_round_trip_is_exact(
items in proptest::collection::vec((any::<[u8; 4]>(), proptest::collection::vec(any::<u8>(), 0..40)), 0..8),
) {
use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd};
let items: Vec<MetadataItem> = items.into_iter().map(|(k, d)| MetadataItem::new(k, d)).collect();
let bytes = write_shmd(&items);
let back = parse_shmd(&bytes).unwrap();
prop_assert_eq!(&back, &items);
prop_assert_eq!(write_shmd(&back), bytes);
}
#[test]
fn metadata_shmd_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
let _ = photocraft_psd::metadata::parse_shmd(&data);
}
/// A built pattern round-trips through both the `Patt`/`Pat2`/`Pat3` block and the `.pat` file,
/// and re-serialising a parsed block is byte-stable.
#[test]
fn pattern_blocks_round_trip(ps in proptest::collection::vec(pattern_strategy(), 0..4)) {
use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block};
let blk = write_pattern_block(&ps).unwrap();
prop_assert_eq!(parse_pattern_block(&blk).unwrap(), ps.clone());
prop_assert_eq!(write_pattern_block(&parse_pattern_block(&blk).unwrap()).unwrap(), blk);
let f = write_pat_file(&ps).unwrap();
prop_assert_eq!(parse_pat_file(&f).unwrap(), ps);
}
#[test]
fn pattern_parsers_never_panic(data in proptest::collection::vec(any::<u8>(), 0..400)) {
let _ = photocraft_psd::patterns::parse_pattern_block(&data);
let _ = photocraft_psd::patterns::parse_pat_file(&data);
}
/// A filter-effects cache with one or more items (each with cached slots and an optional mask)
/// re-serialises and re-parses byte for byte, and the cached planes decode.
#[test]
fn filter_effects_round_trip(items in proptest::collection::vec(("[ -~]{0,8}", any::<bool>()), 0..4)) {
use photocraft_psd::compression::Compression;
use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
use photocraft_psd::layer::Rect;
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
let plane = |v: u8| EffectsPlane::encode(Compression::Rle, &[v; 16], 4, 4, 8).unwrap();
let items: Vec<FilterEffectsItem> = items
.iter()
.map(|(id, mask)| {
let mut slots = vec![None; 26];
slots[0] = Some(plane(10));
slots[25] = Some(plane(255));
FilterEffectsItem { id: id.clone(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) }
})
.collect();
let fx = FilterEffects { version: 3, items };
let bytes = fx.to_bytes();
let back = FilterEffects::parse(&bytes).unwrap();
prop_assert_eq!(&back, &fx);
prop_assert_eq!(back.to_bytes(), bytes);
for it in &back.items {
if let Some((_, m)) = it.decoded_mask() {
prop_assert_eq!(m, vec![128; 16]);
}
}
}
#[test]
fn filter_effects_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
if let Ok(fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) {
for it in &fx.items {
let _ = it.decoded_mask();
}
let _ = fx.to_bytes();
}
}
/// Resolution info is 16 fixed-point bytes; a built value survives a byte round trip.
#[test]
fn resolution_info_round_trips(dpi in 1.0f64..=10_000.0) {
use photocraft_psd::resources::ResolutionInfo;
let ri = ResolutionInfo::from_dpi(dpi);
prop_assert_eq!(ResolutionInfo::from_bytes(&ri.to_bytes()).unwrap(), ri);
}
/// The typed view of an arbitrary resource id over arbitrary data never panics.
#[test]
fn resource_parsed_never_panics(id in any::<u16>(), data in proptest::collection::vec(any::<u8>(), 0..64)) {
use photocraft_psd::resources::ImageResource;
let r = ImageResource::new(id, data);
let _ = r.parsed();
let _ = r.xmp_str();
let _ = photocraft_psd::resources::VersionInfo::from_bytes(&r.data);
}
/// `parsed` (typed) and `check_structure` (strict inner re-parse) over an arbitrary key and
/// arbitrary data never panic, only ever returning `Ok` / `Err` / `None`.
#[test]
fn tagged_block_views_never_panic(key in any::<[u8; 4]>(), data in proptest::collection::vec(any::<u8>(), 0..200)) {
let b = TaggedBlock::new(key, data);
let _ = b.parsed();
let _ = b.check_structure();
}
/// Each well-known typed key over arbitrary data: every typed branch is reached and none panics.
#[test]
fn tagged_known_keys_never_panic(ki in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..64)) {
const KEYS: [&[u8; 4]; 13] =
[b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr"];
let b = TaggedBlock::new(*KEYS[ki], data);
let _ = b.parsed();
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Pure random bytes rarely get past a header, so this damages a *valid* block one byte at a
/// time -- well-formed structure with one byte corrupted, where the interesting inputs live --
/// across the metadata, pattern and filter-effects parsers. None may panic.
#[test]
fn aux_parsers_never_panic_on_damaged_valid_blocks(idx in 0usize..4000, bit in 0u32..8, add in 0u8..=255) {
use photocraft_psd::compression::Compression;
use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
use photocraft_psd::layer::Rect;
use photocraft_psd::metadata::{self, MetadataItem};
use photocraft_psd::patterns::{self, PsdPattern};
let shmd = metadata::write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2, 3, 4]), MetadataItem::new(*b"cust", vec![9; 6])]);
let pattern = PsdPattern {
mode: 3,
width: 4,
height: 3,
name: "d".into(),
id: "0bd2d3ba".into(),
palette: None,
depth: 8,
channels: vec![vec![7u8; 12], vec![8u8; 12], vec![9u8; 12]],
alpha: None,
};
let pat_block = patterns::write_pattern_block(std::slice::from_ref(&pattern)).unwrap();
let pat_file = patterns::write_pat_file(std::slice::from_ref(&pattern)).unwrap();
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
let plane = EffectsPlane::encode(Compression::Rle, &[10u8; 16], 4, 4, 8).unwrap();
let mut slots = vec![None; 26];
slots[0] = Some(plane.clone());
slots[25] = Some(plane.clone());
let fx = FilterEffects {
version: 3,
items: vec![FilterEffectsItem { id: "x".into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: Some((rect, plane)) }],
}
.to_bytes();
let damage = |mut b: Vec<u8>| -> Vec<u8> {
if !b.is_empty() {
let i = idx % b.len();
b[i] ^= 1u8 << bit;
b[i] = b[i].wrapping_add(add);
}
b
};
let _ = metadata::parse_shmd(&damage(shmd));
let _ = patterns::parse_pattern_block(&damage(pat_block));
let _ = patterns::parse_pat_file(&damage(pat_file));
if let Ok(f) = FilterEffects::parse(&damage(fx)) {
for it in &f.items {
let _ = it.decoded_mask();
}
let _ = f.to_bytes();
}
}
/// Every `check_structure` key over arbitrary data: the strict inner re-parse for placed layers,
/// linked files, object effects and filter caches is entered and never panics.
#[test]
fn tagged_structural_keys_never_panic(ki in 0usize..8, data in proptest::collection::vec(any::<u8>(), 0..200)) {
const KEYS: [&[u8; 4]; 8] = [b"PlLd", b"SoLd", b"SoLE", b"lnk2", b"lnk3", b"lnkD", b"lfx2", b"FEid"];
let b = TaggedBlock::new(*KEYS[ki], data);
let _ = b.check_structure();
}
}
+917
View File
@@ -0,0 +1,917 @@
//! `.grd`, `.ase` and `.aco` hostile-input handling.
//!
//! These three parsers are standalone entry points that `PsdFile::from_bytes` never reaches
//! (nothing in `file.rs` calls them), so the `parse.rs` fuzz target cannot exercise them. They are
//! also user-reachable: `grd::parse` backs the gradient-preset import in
//! `engine::preset_import_cmds`, and `ase`/`aco` back swatch import and export. Each module has its
//! own `hostile_input` unit test, but those assert `let _ = parse(...)` on mutations: a panic would
//! abort the test without saying which parser and which byte caused it.
//!
//! What this file adds:
//! 1. every single-bit flip in a valid file, each wrapped in `catch_unwind` so a panic is
//! reported with the exact byte and parser rather than as an opaque abort (§0.8: "parsers and
//! decoders get mutation-fuzz tests run under `catch_unwind`");
//! 2. the round-trip and cap invariants the fuzz targets assert, checked deterministically so CI
//! covers them even though the `fuzz` job is workflow-dispatch only;
//! 3. inputs sized to *exceed* each advertised cap, because a cap asserted only against a file
//! that fits under it is a tautology — it cannot fail, so it proves nothing.
//!
//! The rule under test throughout: hostile input returns `Err`, never a panic and never an
//! unbounded allocation.
use photocraft_psd::aco::{AcoColor, AcoSwatch};
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop};
use photocraft_psd::{PsdError, aco, ase, grd};
use std::panic::{RefUnwindSafe, UnwindSafe, catch_unwind};
fn sample_ase() -> Vec<u8> {
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch as Sw, write};
let sw = |name: &str| Sw { name: name.into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
write(&AseFile { entries: vec![AseEntry::Group { name: "Brand".into(), swatches: vec![sw("Red"), sw("Blue")] }, AseEntry::Swatch(sw("Loose"))] })
.expect("writing a sample .ase")
}
fn sample_aco_swatches() -> Vec<AcoSwatch> {
vec![
AcoSwatch { name: "Pure Red".into(), color: AcoColor::Rgb([65535, 0, 0]) },
AcoSwatch { name: "Cyan ink".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) },
AcoSwatch { name: "Lab".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } },
AcoSwatch { name: "Gray".into(), color: AcoColor::Gray(5000) },
AcoSwatch { name: "Book".into(), color: AcoColor::Other { space: 3, values: [1, 2, 3, 4] } },
]
}
fn sample_aco() -> Vec<u8> {
aco::write(&sample_aco_swatches()).expect("writing a sample .aco")
}
fn sample_grd() -> Vec<u8> {
use photocraft_psd::grd::write;
write(&[
GrdGradient {
name: "Sunset".into(),
noise: false,
stops: vec![
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Background },
],
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.25, 0.5)],
},
GrdGradient { name: "Noisy".into(), noise: true, stops: Vec::new(), opacity: Vec::new() },
])
}
/// Flips every single bit of a valid file and requires that the parser returns or fails without
/// panicking. A flipped length or count must never be trusted into an allocation.
fn bitflip_sweep<T, F>(bytes: &[u8], name: &str, parse: F)
where
F: Fn(&[u8]) -> Result<T, PsdError> + RefUnwindSafe + UnwindSafe,
{
for byte in 0..bytes.len() {
for bit in 0..8 {
let mut b = bytes.to_vec();
b[byte] ^= 1 << bit;
let r = catch_unwind(|| parse(&b));
assert!(r.is_ok(), "{name}: parse panicked after flipping bit {bit} of byte {byte} of {}", bytes.len());
}
}
}
#[test]
fn ase_bitflips_never_panic() {
bitflip_sweep(&sample_ase(), "ase", ase::parse);
}
#[test]
fn aco_bitflips_never_panic() {
bitflip_sweep(&sample_aco(), "aco", aco::parse);
}
#[test]
fn grd_bitflips_never_panic() {
bitflip_sweep(&sample_grd(), "grd", grd::parse);
}
fn assert_finite(values: &[f32], what: &str) {
for v in values {
assert!(v.is_finite(), "{what} component {v} is not finite");
}
}
/// Every number a parsed gradient carries must be finite and inside the ranges the module
/// documents, so a caller never divides by or formats a NaN and never reads past a 0..=1 bound.
fn assert_gradient_sane(g: &GrdGradient) {
for s in &g.stops {
assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location);
assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint);
match s.color {
GrdColor::Rgb(v) => assert_finite(&v, "RGB"),
GrdColor::Hsb(v) => assert_finite(&v, "HSB"),
GrdColor::Cmyk(v) => assert_finite(&v, "CMYK"),
GrdColor::Lab(v) => assert_finite(&v, "Lab"),
GrdColor::Gray(v) => assert!(v.is_finite(), "gray {v} is not finite"),
GrdColor::Foreground | GrdColor::Background => {}
}
}
for &(l, o, m) in &g.opacity {
assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range");
assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1");
assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range");
}
}
/// A gradient file that parses must obey the caps the module advertises and keep every numeric
/// field finite, so callers never divide by or format a NaN.
#[test]
fn grd_parsed_values_respect_caps_and_are_finite() {
// No cap assertion here: the sample has two gradients and two stops, so a cap check would
// hold for any writer and prove nothing. `grd_gradient_count_is_capped` and
// `grd_stop_counts_are_capped` drive inputs over the caps instead.
for g in &grd::parse(&sample_grd()).expect("sample .grd parses") {
assert_gradient_sane(g);
}
}
/// A file declaring more gradients than [`grd::MAX_GRADIENTS`] must be capped at the cap, not
/// trusted: the cap is the only thing between a file's own list length and the allocation it asks
/// for. The input deliberately exceeds the cap, otherwise the assertion below cannot fail.
#[test]
fn grd_gradient_count_is_capped() {
let over = vec![GrdGradient { name: "g".into(), ..Default::default() }; grd::MAX_GRADIENTS + 1];
let bytes = grd::write(&over);
// Precondition: the file really must carry more gradients than the cap, or the assertion
// below would hold for the wrong reason (the writer capping rather than the reader).
// Precondition, by size: a file carrying one gradient more than the cap must be larger than
// the same file at the cap. Counting `Grdn` windows would not work — the object nests — and
// would keep passing even if the writer started capping, which is what this must catch.
let at_cap = grd::write(&over[..grd::MAX_GRADIENTS]).len();
assert!(bytes.len() > at_cap, "the written file is not larger than the capped one: {} vs {at_cap}", bytes.len());
let f = grd::parse(&bytes).expect("a file over the gradient cap still parses");
assert_eq!(f.len(), grd::MAX_GRADIENTS, "gradients must be capped at MAX_GRADIENTS, not taken as declared");
}
/// Same for the per-gradient stop lists: colour stops and transparency stops are capped
/// separately, so both are driven over their cap.
#[test]
fn grd_stop_counts_are_capped() {
let stop = GrdStop { location: 0.5, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) };
let over = GrdGradient { name: "wide".into(), noise: false, stops: vec![stop; grd::MAX_STOPS + 1], opacity: vec![(0.5, 1.0, 0.5); grd::MAX_STOPS + 1] };
let bytes = grd::write(std::slice::from_ref(&over));
// Precondition by size: a file carrying one stop more than the cap must be larger than the
// same file at the cap. Counting `Lctn` windows would not work — it appears in both the colour
// and the transparency list — and would keep passing even if the writer started capping,
// which is exactly what this must catch. Each list is driven on its own, since they are
// capped independently.
let capped_stops = over.stops[..grd::MAX_STOPS].to_vec();
let capped_opacity = over.opacity[..grd::MAX_STOPS].to_vec();
let colour_only_over = GrdGradient { opacity: Vec::new(), ..over.clone() };
let colour_only_capped = GrdGradient { stops: capped_stops, opacity: Vec::new(), ..over.clone() };
assert!(grd::write(&[colour_only_over]).len() > grd::write(&[colour_only_capped]).len(), "colour stops over the cap are not larger than at the cap");
let trans_only_over = GrdGradient { stops: Vec::new(), ..over.clone() };
let trans_only_capped = GrdGradient { stops: Vec::new(), opacity: capped_opacity, ..over };
assert!(grd::write(&[trans_only_over]).len() > grd::write(&[trans_only_capped]).len(), "transparency stops over the cap are not larger than at the cap");
let f = grd::parse(&bytes).expect("a file over the stop cap still parses");
assert_eq!(f[0].stops.len(), grd::MAX_STOPS, "colour stops must be capped at MAX_STOPS");
assert_eq!(f[0].opacity.len(), grd::MAX_STOPS, "transparency stops must be capped at MAX_STOPS");
}
/// `Opct` is a unit float (`UntF` + a 4-byte unit tag), not a `doub`, so patching it needs a
/// different offset from the RGB components. Every numeric field now passes through `finite()`
/// before any clamp, so this pins that a non-finite value is repaired first — saturated, or for a
/// NaN replaced by the field default — rather than reaching `f32::clamp`, which propagates NaN and
/// would hand it straight back out of the parser. Every non-finite shape is tried at both places.
#[test]
fn grd_non_finite_opacity_and_midpoint_do_not_survive() {
// 3.0e38 is finite as both f64 and f32, so it is not caught by the finiteness check and must
// be brought into range by the saturate-then-clamp path; it belongs here to keep that honest.
let bad = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, 3.0e38];
// `doub`: key + 4-byte OSType + 8-byte value. `UntF`: key + OSType + 4-byte unit tag + value.
// `Opct` is the interesting one: a unit float at a different offset from the RGB components, so
// both the `doub` and `UntF` shapes are patched. `Mdpn` is written as an integer here, which
// cannot carry a non-finite value at all, so it is skipped rather than pretended to be covered;
// a hostile file storing `Mdpn` as a double is still guarded by `finite()` and by the fuzz
// target's range assertion.
for key in ["Opct", "Rd ", "Grn ", "Bl "] {
for v in bad {
let mut b = sample_grd();
let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}"));
let skip = match &b[at + 4..at + 8] {
b"doub" => 8usize,
b"UntF" => 12usize,
other => panic!("{key} is stored as {other:?}, which this test cannot patch"),
};
b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes());
let f = grd::parse(&b).unwrap_or_else(|e| panic!("{key} = {v} still parses: {e:?}"));
for g in &f {
assert_gradient_sane(g);
}
}
}
}
/// `num` returns the raw f64 and `finite()` is the single guard every number passes through. This
/// pins that one policy end to end: only a NaN is truly absent and falls back to the documented
/// default (opacity 100 %, midpoint centred); every other shape — including `+/-inf` and a value
/// finite as `f64` but above `f32::MAX` — saturates and then clamps, so a large positive lands at
/// 1.0 and a large negative at 0.0, monotonic, never a snap back to the default.
/// `Mdpn` is written as a `long` by this crate's own writer, so no fixture can smuggle a
/// non-finite value into it and the guard inside `midpoint()` would never be exercised. The file
/// format allows a `double` there, so the descriptor is built by hand with `Mdpn` as a `doub`:
/// otherwise removing that guard changes nothing any test can see.
#[test]
fn grd_midpoint_stored_as_a_double_is_still_sanitised() {
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
for v in [f64::NAN, f64::INFINITY, 1e300, 3.0e38] {
// Driven through `Trns`, where the reader keeps the midpoint, rather than through `Clrs`,
// where a colour stop also needs a colour this descriptor does not carry.
let trns = Descriptor::new("TrnS").with("Mdpn", Value::Double(v));
let grad = Descriptor::new("Grdn").with("Trns", Value::List(vec![Value::Descriptor(trns)]));
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
let mut g = b"8BGR".to_vec();
g.extend_from_slice(&5u16.to_be_bytes());
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
let parsed = grd::parse(&g).unwrap_or_else(|e| panic!("Mdpn = {v} still parses: {e:?}"));
let Some((_, _, m)) = parsed[0].opacity.first() else { panic!("the transparency stop was dropped") };
// Pin the exact value, not just the range. Only a NaN is truly absent, so only it falls
// back to the centred default; every other shape saturates and then clamps, so a large
// positive lands at 1.0 and a large negative at 0.0 — monotonic, never a snap to 0.5.
let expected = if v.is_nan() {
0.5
} else if v > 0.0 {
1.0
} else {
0.0
};
assert_eq!(*m, expected, "a midpoint of {v} must be {expected}, got {m}");
}
}
/// `.aco` stores CMYK as *ink* amounts in memory and the complement on disk: the file holds 0 for
/// 100 % ink and the reader adds the back. Nothing pinned that, so flipping either side would
/// silently invert every CMYK swatch.
#[test]
fn aco_cmyk_is_stored_inverted_and_reads_back_as_ink() {
let swatches = vec![AcoSwatch { name: "k".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }];
let bytes = aco::write(&swatches).expect("writing one CMYK swatch");
// Version 1 section: version u16, count u16, then a 10-byte record.
let space = u16::from_be_bytes([bytes[4], bytes[5]]);
let stored: Vec<u16> = (0..4).map(|i| u16::from_be_bytes([bytes[6 + i * 2], bytes[7 + i * 2]])).collect();
assert_eq!(space, aco::space::CMYK, "the record must be tagged CMYK");
assert_eq!(stored, vec![0, u16::MAX, u16::MAX, u16::MAX - 1234], "the file stores the complement of the ink amounts");
assert_eq!(aco::parse(&bytes).expect("the file parses").swatches, swatches, "the ink amounts must survive the round trip");
}
#[test]
fn grd_unrepresentable_numbers_follow_the_documented_policy() {
// The *second* transparency stop carries opacity 0.25; the first is already 1.0, so patching
// it could not tell "fell back to the default" from "the patch had no effect".
let patch = |key: &[u8], v: f64| {
let mut b = sample_grd();
let at = b.windows(4).rposition(|w| w == key).expect("the sample stores the key");
let skip = match &b[at + 4..at + 8] {
b"doub" => 8usize,
b"UntF" => 12usize,
other => panic!("stored as {other:?}"),
};
b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes());
grd::parse(&b).expect("still parses")
};
// Opacity. A NaN is the only truly absent value, so it alone falls back to the 100 % default.
// Every other shape saturates then clamps, which is monotonic: a large positive stays opaque,
// a large negative becomes transparent. Nothing snaps the wrong way as the number grows.
for (v, expected) in [
(f64::NAN, 1.0), // absent -> the field default (opaque)
(f64::INFINITY, 1.0), // saturates to the top of the range
(f64::NEG_INFINITY, 0.0), // saturates to the bottom
(1e300, 1.0), // over f32::MAX -> saturates up
(-1e300, 0.0), // under -f32::MAX -> saturates down
(3.0e38, 1.0), // finite as f32 -> clamps up
(-3.0e38, 0.0), // finite as f32 -> clamps down
] {
let got = patch(b"Opct", v)[0].opacity[1].1;
assert_eq!(got, expected, "Opct = {v} must read back as {expected}, got {got}");
}
// A colour channel has its own default (zero) for the absent case, and the same monotonic
// saturate-then-clamp for everything else. Pin the exact number: a value reaching the clamp
// from above would come back as 1.0, which is in range and would slip past a range-only check.
for (v, expected) in [
(f64::NAN, 0.0), // absent -> the channel default
(f64::INFINITY, 1.0), // saturates up, then clamps to 1.0
(f64::NEG_INFINITY, 0.0), // saturates down, then clamps to 0.0
(1e300, 1.0), // over f32::MAX -> saturates up
(-1e300, 0.0), // under -f32::MAX -> saturates down
(3.0e38, 1.0), // finite as f32 -> clamps up
(-3.0e38, 0.0), // finite as f32 -> clamps down
] {
let f = patch(b"Bl ", v);
let GrdColor::Rgb([_, _, b]) = f[0].stops[0].color else { panic!("expected an RGB stop") };
assert_eq!(b, expected, "a blue component of {v} must be {expected}, got {b}");
}
}
/// A name long enough that the code-unit cap lands *between* the halves of a surrogate pair must
/// not be cut in half: a lone surrogate is ill-formed UTF-16, and writing it would make the file
/// unreadable to anything strict. Capping has to drop the whole character instead, and the file
/// must then survive its own round trip unchanged.
#[test]
fn aco_and_ase_never_split_a_surrogate_pair() {
// "a" plus 600 emoji: 1 + 1200 units, so a 1024-unit cap lands mid-pair.
let name = format!("a{}", "\u{1F600}".repeat(600));
assert!(name.encode_utf16().count() > ase::MAX_NAME_UNITS, "the name must be longer than the cap");
let swatches = vec![aco::AcoSwatch { name: name.clone(), color: aco::AcoColor::Gray(5000) }];
let once = aco::write(&swatches).expect("writing a long non-ASCII name is capped, not refused");
let back = aco::parse(&once).expect("the written file parses");
// The exact expected name, not merely "it is valid UTF-16": the cap must drop the whole
// character that does not fit, leaving "a" plus 511 complete pairs and nothing half-written.
let expected = format!("a{}", "\u{1F600}".repeat(511));
assert_eq!(back.swatches[0].name, expected, "the capped name must be whole characters only");
assert_eq!(aco::write(&back.swatches).unwrap(), once, "the file must be stable across a round trip");
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write};
let file = AseFile { entries: vec![AseEntry::Swatch(AseSwatch { name, color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process })] };
let a1 = write(&file).expect("writing a long non-ASCII name is capped, not refused");
let ab = ase::parse(&a1).expect("the written file parses");
let ase::AseEntry::Swatch(s) = &ab.entries[0] else { panic!("expected a loose swatch") };
assert_eq!(s.name, expected, "the capped name must be whole characters only");
assert_eq!(write(&ab).unwrap(), a1, "the file must be stable across a round trip");
}
/// Locations, midpoints and opacity are documented as 0..=1. A hostile file stores raw integers,
/// so the values are driven well outside that range and must come back clamped.
#[test]
fn grd_out_of_range_numbers_are_clamped() {
let g = GrdGradient {
name: "out of range".into(),
noise: false,
stops: vec![
GrdStop { location: 2.0, midpoint: 5.0, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
GrdStop { location: -3.0, midpoint: -1.0, color: GrdColor::Rgb([0.0, 1.0, 0.0]) },
],
opacity: vec![(9.0, 5.0, 7.0), (-2.0, -1.0, 42.0)],
};
let f = grd::parse(&grd::write(&[g])).expect("out-of-range numbers still parse");
assert_gradient_sane(&f[0]);
// Range membership alone would be a tautology once the values are already in range: assert the
// exact clamped result, so a clamp silently degrading into a pass-through is caught. Positive
// over-range -> 1.0, negative -> 0.0.
assert_eq!(f[0].stops[0].location, 1.0, "location 2.0 must clamp to 1.0");
assert_eq!(f[0].stops[0].midpoint, 1.0, "midpoint 5.0 must clamp to 1.0");
assert_eq!(f[0].stops[1].location, 0.0, "location -3.0 must clamp to 0.0");
assert_eq!(f[0].stops[1].midpoint, 0.0, "midpoint -1.0 must clamp to 0.0");
assert_eq!(f[0].opacity[0], (1.0, 1.0, 1.0), "all-positive over-range opacity must clamp to 1.0");
assert_eq!(f[0].opacity[1], (0.0, 0.0, 1.0), "location/opacity clamp to 0.0, midpoint 42.0 to 1.0");
}
/// `num` returns the raw f64 unfiltered; `finite()` is the single guard, and a NaN is not the only
/// non-finite f32 it has to handle: a value that is finite as f64 and above `f32::MAX` (1e300)
/// would become an infinity on a bare cast, so `finite()` saturates it. Every numeric field must
/// come back finite, so both doors are checked.
#[test]
fn grd_huge_f64_does_not_overflow_to_infinity() {
for key in ["Rd ", "Grn ", "Bl "] {
let mut b = sample_grd();
let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}"));
assert_eq!(&b[at + 4..at + 8], b"doub", "{key} is stored as a double");
b[at + 8..at + 16].copy_from_slice(&1e300f64.to_be_bytes());
let f = grd::parse(&b).expect("a huge but finite component still parses");
for g in &f {
assert_gradient_sane(g);
}
}
}
/// A hostile file carrying a NaN must have it replaced, not handed on: `Rd ` in the sample is a
/// double, so patching those eight bytes is enough to try to smuggle a NaN past the reader.
#[test]
fn grd_non_finite_numbers_do_not_survive_the_reader() {
let mut b = sample_grd();
let at = b.windows(4).position(|w| w == b"Rd ").expect("the sample stores an RGB component");
assert_eq!(&b[at + 4..at + 8], b"doub", "an RGB component is stored as a double");
b[at + 8..at + 16].copy_from_slice(&f64::NAN.to_be_bytes());
let f = grd::parse(&b).expect("a NaN component still parses");
assert!(!f.is_empty());
for g in &f {
assert_gradient_sane(g);
}
}
/// An `.ase` that parses must stay within the swatch cap and re-serialize to the same number of
/// colours, so an import never silently drops or invents entries.
#[test]
fn ase_round_trip_preserves_swatch_count() {
use photocraft_psd::ase::write;
let f = ase::parse(&sample_ase()).expect("sample .ase parses");
// No cap assertion: three swatches cannot exceed a cap of 100 000. `ase_swatch_cap_is_enforced_
// on_hostile_input` drives a file that does.
let n = f.swatch_count();
let out = write(&f).expect("re-writing a parsed .ase");
let back = ase::parse(&out).expect("the rewritten .ase parses");
assert_eq!(back.swatch_count(), n, "swatch count changed across a round trip");
}
/// A file with more colour blocks than [`ase::MAX_SWATCHES`] must be refused. Each block here is
/// the smallest legal colour block, so the file is small but the declared count is over the cap.
#[test]
fn ase_swatch_cap_is_enforced_on_hostile_input() {
let mut body = Vec::new();
body.extend_from_slice(&0u16.to_be_bytes()); // name length 0
body.extend_from_slice(b"Gray");
body.extend_from_slice(&0f32.to_be_bytes()); // gray component
body.extend_from_slice(&2u16.to_be_bytes()); // colour type: process
let mut block = Vec::new();
block.extend_from_slice(&1u16.to_be_bytes()); // block type: colour entry
block.extend_from_slice(&(body.len() as u32).to_be_bytes());
block.extend_from_slice(&body);
let total = ase::MAX_SWATCHES + 1;
let mut f = b"ASEF".to_vec();
f.extend_from_slice(&1u16.to_be_bytes()); // major
f.extend_from_slice(&0u16.to_be_bytes()); // minor
f.extend_from_slice(&(total as u32).to_be_bytes());
for _ in 0..total {
f.extend_from_slice(&block);
}
assert!(matches!(ase::parse(&f), Err(PsdError::LimitExceeded(_))), "a file over MAX_SWATCHES must be refused, not silently truncated");
// The same file one block under the cap is accepted, so the cap is what refuses it.
f.truncate(f.len() - block.len());
let trimmed = total - 1;
// signature (4) + major (2) + minor (2) puts the block count at offset 8.
f[8..12].copy_from_slice(&(trimmed as u32).to_be_bytes());
assert!(ase::parse(&f).is_ok(), "a file exactly at MAX_SWATCHES must still parse");
}
/// A name longer than [`ase::MAX_NAME_UNITS`] is truncated on read rather than refused: the file
/// is legal, only the name is long, and `read_name` is the only thing standing between the declared
/// length and the string it builds.
#[test]
fn ase_over_long_name_is_truncated_on_read() {
let units = ase::MAX_NAME_UNITS + 976; // 2000
let mut body = Vec::new();
body.extend_from_slice(&(units as u16).to_be_bytes()); // name length, no trailing null here
for _ in 0..units {
body.extend_from_slice(&(b'x' as u16).to_be_bytes());
}
body.extend_from_slice(b"Gray");
body.extend_from_slice(&0f32.to_be_bytes());
body.extend_from_slice(&2u16.to_be_bytes());
let mut f = b"ASEF".to_vec();
f.extend_from_slice(&1u16.to_be_bytes());
f.extend_from_slice(&0u16.to_be_bytes());
f.extend_from_slice(&1u32.to_be_bytes()); // one block
f.extend_from_slice(&1u16.to_be_bytes()); // colour entry
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
let parsed = ase::parse(&f).expect("an over-long name is truncated, not refused");
let ase::AseEntry::Swatch(s) = &parsed.entries[0] else { panic!("expected a loose swatch") };
assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS");
}
/// An `.aco` that parses must re-serialize to the same swatch count, with no name over the cap.
#[test]
fn aco_round_trip_preserves_swatch_count() {
use photocraft_psd::aco::write;
// No cap assertion here. `aco::MAX_SWATCHES` is `u16::MAX` against a `u16` on-disk count, so it
// is unfalsifiable by construction, and the sample names are far shorter than the name cap;
// `aco_over_long_name_is_truncated_on_read` is where that cap is actually driven.
let f = aco::parse(&sample_aco()).expect("sample .aco parses");
let out = write(&f.swatches).expect("re-writing parsed swatches");
let back = aco::parse(&out).expect("the rewritten .aco parses");
assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip");
}
/// A version 2 section whose name declares more units than [`aco::MAX_NAME_UNITS`] must come back
/// truncated. The count is written by hand because `write` will not emit one.
#[test]
fn aco_over_long_name_is_truncated_on_read() {
let units = 2000usize;
let mut f = Vec::new();
f.extend_from_slice(&2u16.to_be_bytes()); // version 2 section
f.extend_from_slice(&1u16.to_be_bytes()); // one swatch
f.extend_from_slice(&0u16.to_be_bytes()); // colour space: RGB
for v in [1u16, 2, 3, 0] {
f.extend_from_slice(&v.to_be_bytes()); // the four stored values
}
// The count includes the terminating null, as `write` does and as the format requires.
f.extend_from_slice(&((units + 1) as u32).to_be_bytes());
for _ in 0..units {
f.extend_from_slice(&(b'x' as u16).to_be_bytes());
}
f.extend_from_slice(&0u16.to_be_bytes()); // terminating null
let parsed = aco::parse(&f).expect("an over-long name is truncated, not refused");
assert_eq!(parsed.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS");
}
/// `write` must cap too, or a long name from a caller is written whole. The declared length in
/// the version 2 record counts the terminating null, so it must read back as MAX_NAME_UNITS + 1.
#[test]
fn aco_write_caps_the_name() {
let swatches = vec![AcoSwatch { name: "x".repeat(5000), color: AcoColor::Gray(5000) }];
let bytes = aco::write(&swatches).expect("an over-long name is capped on write, not refused");
// Names go out as UTF-16 big-endian, so 'x' is the byte pair 00 78.
let run = [0u8, b'x'];
let mut needle = Vec::new();
for _ in 0..8 {
needle.extend_from_slice(&run);
}
let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file");
let declared = u32::from_be_bytes([bytes[at - 4], bytes[at - 3], bytes[at - 2], bytes[at - 1]]);
assert_eq!(declared as usize, aco::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null");
let back = aco::parse(&bytes).expect("the written file parses");
assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS);
}
/// `write` must cap an over-long name too, and it must count the terminating null in the u16
/// length field it writes. Without the cap that field would wrap, but only once the name passes
/// 65 535 units — so the case that proves it uses 70 000, not merely "long".
#[test]
fn ase_write_caps_the_name() {
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write};
let sw = AseSwatch { name: "x".repeat(70_000), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
let bytes = write(&AseFile { entries: vec![AseEntry::Swatch(sw)] }).expect("an over-long name is capped, not refused");
let run = [0u8, b'x'];
let mut needle = Vec::new();
for _ in 0..8 {
needle.extend_from_slice(&run);
}
let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file");
let declared = u16::from_be_bytes([bytes[at - 2], bytes[at - 1]]) as usize;
assert_eq!(declared, ase::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null");
let back = ase::parse(&bytes).expect("the written file parses");
let ase::AseEntry::Swatch(s) = &back.entries[0] else { panic!("expected a loose swatch") };
assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS);
}
/// Truncating a `.grd` anywhere must be an error: unlike `.aco` there is no optional second
/// section, so a prefix can never be a complete file.
#[test]
fn truncate_grd_is_always_an_error() {
let b = sample_grd();
for cut in 0..b.len() {
let r = catch_unwind(|| grd::parse(&b[..cut]));
assert!(r.is_ok(), "grd: parse panicked on a {cut}/{}-byte prefix", b.len());
assert!(r.unwrap().is_err(), "grd: truncation at {cut}/{} parsed successfully", b.len());
}
assert!(grd::parse(&b).is_ok(), "grd: the full file must parse");
}
/// Truncating an `.ase` anywhere must be an error: the block count is fixed up front, so a prefix
/// cannot contain the number of blocks the header claims.
#[test]
fn truncate_ase_is_always_an_error() {
let b = sample_ase();
for cut in 0..b.len() {
let r = catch_unwind(|| ase::parse(&b[..cut]));
assert!(r.is_ok(), "ase: parse panicked on a {cut}/{}-byte prefix", b.len());
assert!(r.unwrap().is_err(), "ase: truncation at {cut}/{} parsed successfully", b.len());
}
assert!(ase::parse(&b).is_ok(), "ase: the full file must parse");
}
/// Truncating an `.aco` inside its first section must be an error. A cut that lands exactly after a
/// complete first section is *not* an error — the reader stops there, having read the version 1
/// section, and ignores whatever follows — so the sweep stops at that boundary rather than
/// claiming every prefix fails. The boundary is derived from the sample so adding a swatch
/// cannot silently weaken the sweep.
#[test]
fn truncate_aco_inside_first_section_is_an_error() {
let b = sample_aco();
// version u16 + count u16, then one 10-byte colour record per swatch.
let first_section = 4 + sample_aco_swatches().len() * 10;
assert!(first_section <= b.len(), "the sample must contain a whole first section");
for cut in 0..first_section {
let r = catch_unwind(|| aco::parse(&b[..cut]));
assert!(r.is_ok(), "aco: parse panicked on a {cut}/{first_section}-byte prefix");
assert!(r.unwrap().is_err(), "aco: truncation inside the first section at {cut} parsed successfully");
}
match catch_unwind(|| aco::parse(&b[..first_section])) {
Ok(Ok(_)) => {}
Ok(Err(e)) => panic!("aco: a complete first section must parse, got {e:?}"),
Err(_) => panic!("aco: a complete first section panicked"),
}
assert!(aco::parse(&b).is_ok(), "aco: the full file must parse");
}
/// Structural `.ase` cases the block reader has to survive: a group end with no start, a group
/// started inside another, a block length that disagrees with what the body consumes, and an
/// unknown colour model whose components are cut short. Each is a clean error or a clean parse,
/// never a panic and never a read past the block.
#[test]
fn ase_structurally_hostile_blocks() {
use photocraft_psd::ase::{AseColor, AseEntry};
let header = |count: u32| {
let mut f = b"ASEF".to_vec();
f.extend_from_slice(&1u16.to_be_bytes());
f.extend_from_slice(&0u16.to_be_bytes());
f.extend_from_slice(&count.to_be_bytes());
f
};
let block = |kind: u16, body: &[u8]| {
let mut b = kind.to_be_bytes().to_vec();
b.extend_from_slice(&(body.len() as u32).to_be_bytes());
b.extend_from_slice(body);
b
};
let group_start = |name: &str| {
// The name is a u16 code-unit count followed by the units themselves, not raw bytes.
let units: Vec<u16> = name.encode_utf16().collect();
let mut b = (units.len() as u16).to_be_bytes().to_vec();
for u in units {
b.extend_from_slice(&u.to_be_bytes());
}
block(0xC001, &b)
};
let group_end = || block(0xC002, &[]);
let colour = |model: &[u8], floats: &[u8]| {
let mut b = 0u16.to_be_bytes().to_vec();
b.extend_from_slice(model);
b.extend_from_slice(floats);
b.extend_from_slice(&2u16.to_be_bytes());
block(0x0001, &b)
};
// A group end with no start: documented as ignored, so this parses and yields nothing.
let mut f = header(1);
f.extend_from_slice(&group_end());
let parsed = ase::parse(&f).expect("an unbalanced group end must parse");
assert!(parsed.entries.is_empty(), "a stray group end must not invent an entry or an empty group");
// A group started inside another: the outer one closes, both groups are emitted.
let mut f = header(3);
f.extend_from_slice(&group_start("outer"));
f.extend_from_slice(&group_start("inner"));
f.extend_from_slice(&colour(b"Gray", &1f32.to_be_bytes()));
let parsed = ase::parse(&f).expect("a nested group start must parse");
assert_eq!(parsed.swatch_count(), 1, "the colour must land in exactly one group");
// The message above is only true if the colour is in the *inner* group; check it.
let inner_holds_it = parsed.entries.iter().any(|e| matches!(e, AseEntry::Group { name, swatches } if name == "inner" && swatches.len() == 1));
assert!(inner_holds_it, "the colour must land in the inner group, not the outer one");
let names: Vec<&str> = parsed
.entries
.iter()
.filter_map(|e| match e {
AseEntry::Group { name, .. } => Some(name.as_str()),
AseEntry::Swatch(_) => None,
})
.collect();
assert_eq!(names, vec!["outer", "inner"], "both groups must be closed and emitted");
// A block length shorter than the body it describes: the colour's floats are cut off.
// Gray carries exactly one component, so declaring the block one byte shorter leaves that
// component half-read. It must be refused, not guessed at.
let mut short = 0u16.to_be_bytes().to_vec();
short.extend_from_slice(b"Gray");
short.extend_from_slice(&0f32.to_be_bytes());
short.extend_from_slice(&2u16.to_be_bytes()); // colour type: process
let mut f = header(1);
let mut b = 0x0001u16.to_be_bytes().to_vec();
b.extend_from_slice(&((short.len() - 4) as u32).to_be_bytes());
b.extend_from_slice(&short);
f.extend_from_slice(&b);
let r = catch_unwind(|| ase::parse(&f));
assert!(r.is_ok(), "a truncated colour component must not panic");
assert!(r.unwrap().is_err(), "a colour component cut short must be refused, not guessed at");
// A block that ends after the components, with no colour type at all, is *accepted*: some
// writers omit it and the reader defaults it to process. Pinning that, because it is the one
// place this parser is deliberately lenient.
let mut f = header(1);
let mut b = 0x0001u16.to_be_bytes().to_vec();
b.extend_from_slice(&(short.len().saturating_sub(2) as u32).to_be_bytes());
b.extend_from_slice(&short[..short.len() - 2]);
f.extend_from_slice(&b);
let parsed = ase::parse(&f).expect("a colour block with no colour type is accepted by design");
assert_eq!(parsed.swatch_count(), 1, "it must still yield exactly one swatch");
// An unknown colour model keeps whatever bytes follow it verbatim.
// The model tag is four bytes; give it four, then a known payload, so "kept verbatim" can be
// checked rather than merely asserted.
let mut f = header(1);
let payload = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
f.extend_from_slice(&colour(b"XYZ ", &payload));
let parsed = ase::parse(&f).expect("an unknown colour model is kept verbatim");
assert_eq!(parsed.swatch_count(), 1, "an unknown model still yields one swatch");
let AseEntry::Swatch(sw) = &parsed.entries[0] else { panic!("expected a loose swatch") };
let AseColor::Other { model, data } = &sw.color else { panic!("an unknown model must be kept as Other, got {:?}", sw.color) };
assert_eq!(model, b"XYZ ", "the model tag must be preserved verbatim");
assert_eq!(data.as_slice(), &payload, "the component bytes must be preserved verbatim");
}
/// A `.aco` whose version 1 and version 2 sections disagree about the number of swatches is the
/// realistic hostile shape: Photoshop writes both, and a corrupted length lands in one of them.
/// The version 2 names must still line up with the version 1 colours they belong to.
#[test]
fn aco_mismatched_section_counts_do_not_panic() {
// version 1 with two colours and no names, then a version 2 section claiming five.
let mut f = vec![0u8, 1, 0, 2];
for _ in 0..2 {
f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]);
}
f.extend_from_slice(&[0, 2, 0, 5]);
for _ in 0..5 {
f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]);
// An .aco record has no colour-type field, so a one-character name is exactly two units:
// two u16 values, not one.
f.extend_from_slice(&2u32.to_be_bytes());
f.extend_from_slice(&[0, 78, 0, 0]); // 'N' and its null
}
let r = catch_unwind(|| aco::parse(&f));
assert!(r.is_ok(), "mismatched section counts must not panic");
let parsed = r.unwrap().expect("the version 2 section is the one with names");
assert_eq!(parsed.swatches.len(), 5, "the named section is authoritative when present");
for sw in &parsed.swatches {
assert_eq!(sw.name, "N", "each version 2 record must keep its name beside its own colour");
}
}
/// Deep descriptor nesting is the one hostile shape that aborts rather than returning: a stack
/// overflow is not a panic, so `catch_unwind` cannot catch it and it would take the process down.
/// `descriptor` bounds it at `MAX_DEPTH` and its own unit test drives `Descriptor::from_bytes`
/// directly; this drives the same limit through `grd::parse`, which is the path a user's gradient
/// preset actually takes, and asserts the specific error rather than merely surviving.
///
/// The previous version of this test built `b"null"` repeated 2000 times, which is not a descriptor
/// at all — no item counts, no `Objc` tags — so the parser rejected it on the first field and
/// nothing ever recursed. It passed for the wrong reason.
#[test]
fn grd_deeply_nested_descriptor_hits_the_depth_limit() {
use photocraft_psd::descriptor::{Descriptor, MAX_DEPTH, Value, VersionedDescriptor};
let mut d = Descriptor::new("null");
for _ in 0..(MAX_DEPTH + 5) {
d = Descriptor::new("null").with("in ", Value::Descriptor(d));
}
let mut g = b"8BGR".to_vec();
g.extend_from_slice(&5u16.to_be_bytes());
g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
// This is the real assertion: nesting past the limit is refused, not recursed into.
assert!(matches!(grd::parse(&g), Err(PsdError::LimitExceeded(_))), "nesting past MAX_DEPTH must be refused through the .grd path, not recursed into");
// The limit discriminates rather than being a blanket refusal: a shallow nesting is accepted
// (rejected later, for holding no gradients — a different error), while a deep one is a depth
// refusal. Each wrapper adds more than one unit of depth, so the exact crossover is an
// implementation detail; what matters is that normal files pass and runaway ones are stopped.
let build = |levels: usize| {
let mut d = Descriptor::new("null");
for _ in 0..levels {
d = Descriptor::new("null").with("in ", Value::Descriptor(d));
}
let mut g = b"8BGR".to_vec();
g.extend_from_slice(&5u16.to_be_bytes());
g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
g
};
assert!(!matches!(grd::parse(&build(4)), Err(PsdError::LimitExceeded(_))), "a shallow descriptor must not be refused for depth");
assert!(
matches!(grd::parse(&build(MAX_DEPTH + 1)), Err(PsdError::LimitExceeded(_))),
"a descriptor past the depth limit must be a depth refusal, not an abort"
);
}
/// Every colour model's components must come back finite and inside their documented range, not
/// just RGB. The writer only emits RGB and foreground/background stops, so each model is built by
/// hand with an out-of-range or non-representable value in every component, then driven through
/// `grd::parse`. Without this, the clamps on HSB, CMYK, Gray and the L* channel — and the finite
/// guard on the Lab a*/b* channels — have no test and can be removed unnoticed.
#[test]
fn grd_every_colour_model_is_clamped_and_finite() {
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
// Build a one-stop gradient whose colour is `class_id` with the given (key, value) components.
let build = |class_id: &str, comps: &[(&str, f64)]| -> Vec<u8> {
let mut colour = Descriptor::new(class_id);
for (k, v) in comps {
colour = colour.with(k, Value::Double(*v));
}
let stop = Descriptor::new("Clrt")
.with("Clr ", Value::Descriptor(colour))
.with("Type", Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("Clry"), value: photocraft_psd::descriptor::Id::new("UsrS") })
.with("Lctn", Value::Integer(0))
.with("Mdpn", Value::Integer(50));
let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)]));
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
let mut g = b"8BGR".to_vec();
g.extend_from_slice(&5u16.to_be_bytes());
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
g
};
let hostile = [1e300, 3.0e38, -3.0e38, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
for &x in &hostile {
// The exact boundary each clamp must land on, so a weaker clamp (e.g. a*/b* to -100..=100
// instead of -128..=127, or a unit range to 0..=0.5) is caught, not just an out-of-range
// value. A NaN becomes 0.0 (the finite guard's fallback for `f`), a positive saturates to
// the top of the range, a negative to the bottom.
let unit = if x.is_nan() || x < 0.0 { 0.0f32 } else { 1.0 };
let lab_l = if x.is_nan() || x < 0.0 { 0.0f32 } else { 100.0 };
let lab_ab = if x.is_nan() {
0.0f32
} else if x > 0.0 {
127.0
} else {
-128.0
};
// RGB components are 0..=255 before the /255; the result must land in 0..=1.
let f = grd::parse(&build("RGBC", &[("Rd ", x), ("Grn ", x), ("Bl ", x)])).expect("RGB parses");
let GrdColor::Rgb(rgb) = f[0].stops[0].color else { panic!("expected an RGB stop") };
for v in rgb {
assert_eq!(v, unit, "RGB component for input {x} must saturate to {unit}");
}
// HSB: hue 0..=360, saturation and brightness 0..=1.
let f = grd::parse(&build("HSBC", &[("H ", x), ("Strt", x), ("Brgh", x)])).expect("HSB parses");
if let GrdColor::Hsb([h, sat, br]) = f[0].stops[0].color {
assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} out of range for input {x}");
assert_eq!(sat, unit, "saturation for input {x} must saturate to {unit}");
assert_eq!(br, unit, "brightness for input {x} must saturate to {unit}");
} else {
panic!("expected an HSB stop");
}
// CMYK: every ink 0..=1.
let f = grd::parse(&build("CMYC", &[("Cyn ", x), ("Mgnt", x), ("Ylw ", x), ("Blck", x)])).expect("CMYK parses");
if let GrdColor::Cmyk(c) = f[0].stops[0].color {
for v in c {
assert_eq!(v, unit, "CMYK ink for input {x} must saturate to {unit}");
}
} else {
panic!("expected a CMYK stop");
}
// Lab: L* 0..=100, a*/b* bounded to the CIELAB gamut -128..=127.
let f = grd::parse(&build("LbCl", &[("Lmnc", x), ("A ", x), ("B ", x)])).expect("Lab parses");
if let GrdColor::Lab([l, a, b]) = f[0].stops[0].color {
assert_eq!(l, lab_l, "L* for input {x} must saturate to {lab_l}");
assert_eq!(a, lab_ab, "a* for input {x} must saturate to the gamut edge {lab_ab}");
assert_eq!(b, lab_ab, "b* for input {x} must saturate to the gamut edge {lab_ab}");
} else {
panic!("expected a Lab stop");
}
// Gray: 0..=1.
let f = grd::parse(&build("Grsc", &[("Gry ", x)])).expect("Gray parses");
if let GrdColor::Gray(g) = f[0].stops[0].color {
assert_eq!(g, unit, "gray for input {x} must saturate to {unit}");
} else {
panic!("expected a Gray stop");
}
}
}
/// Hue is circular, so an out-of-range hue must *wrap*, not clamp: 370 degrees is 10, not 360, and
/// -10 is 350, not 0. Clamping would hand back a different colour; this pins the wrap so a
/// regression to `clamp` is caught (both produce in-range values, so a range check alone would not).
#[test]
fn grd_hue_wraps_rather_than_clamping() {
use photocraft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor};
let build = |hue: f64| {
let colour = Descriptor::new("HSBC").with("H ", Value::Double(hue)).with("Strt", Value::Double(50.0)).with("Brgh", Value::Double(50.0));
let stop = Descriptor::new("Clrt")
.with("Clr ", Value::Descriptor(colour))
.with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") })
.with("Lctn", Value::Integer(0))
.with("Mdpn", Value::Integer(50));
let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)]));
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
let mut g = b"8BGR".to_vec();
g.extend_from_slice(&5u16.to_be_bytes());
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
g
};
let hue_of = |bytes: &[u8]| -> f32 {
let f = grd::parse(bytes).expect("parses");
let GrdColor::Hsb([h, _, _]) = f[0].stops[0].color else { panic!("expected an HSB stop") };
h
};
assert!((hue_of(&build(370.0)) - 10.0).abs() < 0.01, "370 degrees must wrap to 10, not clamp to 360");
assert!((hue_of(&build(-10.0)) - 350.0).abs() < 0.01, "-10 degrees must wrap to 350, not clamp to 0");
// And a value already in range is unchanged.
assert!((hue_of(&build(123.0)) - 123.0).abs() < 0.01, "an in-range hue must be unchanged");
// Exactly 360 is in range and legal, so it is kept, not wrapped to 0 (a different position for
// anything that interpolates in HSB). Unconditional `rem_euclid(360)` would map it to 0, so this
// pins the in-range guard that keeps the boundary intact.
assert_eq!(hue_of(&build(360.0)), 360.0, "an in-range hue of exactly 360 must be preserved, not wrapped to 0");
assert_eq!(hue_of(&build(0.0)), 0.0, "a hue of 0 must stay 0");
}
/// A signature-only or empty file must be a clean error, never a panic and never an
/// out-of-bounds read.
#[test]
fn empty_and_signature_only_inputs_error() {
fn no_panic_then_err<T>(r: Result<Result<T, PsdError>, Box<dyn core::any::Any + Send>>, what: &str) {
match r {
Ok(Ok(_)) => panic!("{what}: an empty file must be a clean error, not parse"),
Ok(Err(_)) => {}
Err(_) => panic!("{what}: parse panicked on an empty file"),
}
}
no_panic_then_err(catch_unwind(|| grd::parse(b"")), "grd");
no_panic_then_err(catch_unwind(|| ase::parse(b"")), "ase");
no_panic_then_err(catch_unwind(|| aco::parse(b"")), "aco");
assert!(matches!(grd::parse(b"8BGR"), Err(PsdError::UnexpectedEof { .. })));
assert!(matches!(grd::parse(b"8BG"), Err(PsdError::UnexpectedEof { .. })));
assert!(ase::parse(b"ASEF").is_err());
assert!(aco::parse(b"").is_err());
}
+191 -31
View File
@@ -1762,6 +1762,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let mut close = None;
let mut tab_action = None;
let mut tab_move = None;
let mut tear_off = None;
let (mut focus_open, mut cancel_open) = (None, None);
let focused_open = app.jobs.focus.is_some();
// Layers dragged over a tab show its document (`layer_transfer`).
@@ -1851,18 +1852,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.clicked() {
activate = Some(i);
}
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
tab_drop_line(ui, &placed, p.x, &t);
}
if resp.drag_stopped() {
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
let to = tab_move_target(i, tab_drop_index(&placed, x));
if to != i {
tab_move = Some((i, to));
}
match tab_drag(ui, &resp, i, r, row, &placed, &t) {
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
None => {}
}
let has_path = st.path.is_some();
resp.context_menu(|ui| {
@@ -1925,6 +1918,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
app.ui.status = e;
app.ui.status_error = true;
}
float_torn_off(app, ui.ctx(), tear_off);
if let Some((id, params)) = tab_action
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
{
@@ -1968,6 +1962,70 @@ fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: boo
/// Where a tab dragged to `x` would land: before the first shown tab whose middle is right of
/// `x`, else after the last shown tab (its document index + 1), like [`TabStrip::slot`].
/// How far (points) above or below the tab strip a dragged tab must be to tear off (#3733).
const TEAR_OFF: f32 = 24.0;
/// Whether a tab dragged to `p` has left the strip and floats into its own window, as in
/// Photoshop; sideways drags along the strip still reorder (#217).
fn torn_off(strip: Rect, p: egui::Pos2) -> bool {
p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF
}
/// What a finished document-tab drag asks for.
#[derive(Clone, Copy, Debug, PartialEq)]
enum TabDrop {
/// Reorder the tab to this index.
Move(usize),
/// Float the document in its own OS window at this screen position (points), when known.
Float(Option<[f32; 2]>),
}
/// Drag a document tab: along the strip it reorders (a line shows where it will land); off the
/// strip it tears off into a floating window that can go anywhere, other screens included
/// (an outline under the pointer shows it). Returns the drop once the drag ends.
fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect, placed: &[(usize, Rect)], t: &crate::theme::Tokens) -> Option<TabDrop> {
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
if torn_off(strip, p) {
let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0));
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off")));
painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12));
painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
} else {
tab_drop_line(ui, placed, p.x, t);
}
}
if !resp.drag_stopped() {
return None;
}
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos()));
if let Some(p) = p.filter(|&p| torn_off(strip, p)) {
// The pointer is in this window's points; the OS wants screen coordinates. Keep the new
// window's title bar under the pointer, as the ghost showed.
let at = ui.ctx().input(|i| i.viewport().inner_rect).map(|inner| {
let s = inner.min + p.to_vec2() - egui::vec2(40.0, 12.0);
[s.x, s.y]
});
return Some(TabDrop::Float(at));
}
let to = tab_move_target(i, tab_drop_index(placed, p.map_or(r.center().x, |p| p.x)));
(to != i).then_some(TabDrop::Move(to))
}
/// Float a torn-off tab's document in its own window (`window.arrange.floatInWindow`).
fn float_torn_off(app: &mut PhotocraftApp, ctx: &egui::Context, tear_off: Option<(usize, Option<[f32; 2]>)>) {
let Some((doc, at)) = tear_off else { return };
let params = match at {
Some(at) => json!({"document": doc, "position": at}),
None => json!({"document": doc}),
};
if let Err(e) = crate::menus::invoke(app, ctx, "window.arrange.floatInWindow", params) {
app.ui.status = e;
app.ui.status_error = true;
}
}
fn tab_drop_index(placed: &[(usize, Rect)], x: f32) -> usize {
match placed.iter().find(|(_, r)| r.center().x >= x) {
Some(&(i, _)) => i,
@@ -2027,7 +2085,7 @@ const STUDIO_TAB_GAP: f32 = 4.0;
fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String {
let Some(st) = app.session.documents().get(i) else { return String::new() };
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id));
let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some());
let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() };
let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default();
@@ -2051,6 +2109,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let (mut activate, mut close) = (None, None);
let mut tab_action = None;
let mut tab_move = None;
let mut tear_off = None;
let tab_count = app.session.documents().len();
let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx());
let mut drag_over = None;
@@ -2111,18 +2170,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.clicked() {
activate = Some(i);
}
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
tab_drop_line(ui, &placed, p.x, &t);
}
if resp.drag_stopped() {
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
let to = tab_move_target(i, tab_drop_index(&placed, x));
if to != i {
tab_move = Some((i, to));
}
match tab_drag(ui, &resp, i, r, strip, &placed, &t) {
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
None => {}
}
let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some());
resp.context_menu(|ui| {
@@ -2179,6 +2230,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
app.ui.status = e;
app.ui.status_error = true;
}
float_torn_off(app, ui.ctx(), tear_off);
if let Some((id, params)) = tab_action
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
{
@@ -2779,7 +2831,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// zooms nor slides the image towards the pointer.
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
if nz != view.zoom {
zoom_about(&mut view, &xf, p, nz, false, ppp);
// Over the pasteboard the anchor is the image's nearest point (#3720).
let at = wheel_anchor(&xf, p, view.doc_size);
zoom_about(&mut view, &xf, at, nz, false, ppp);
}
}
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
@@ -3515,6 +3569,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift);
}
}
if primary && hand_pan && response.dragged() {
crate::transform_tool::draw_overlay(app, &painter, &xf);
}
app.tool_override = None;
if let Some(b) = layer_edges_rect(app, idx) {
painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside));
@@ -3557,6 +3614,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under
/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the
/// centre of the view instead (#204).
/// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image,
/// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray
/// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps
/// the image in view. Clamped in document space, so a rotated or flipped view behaves the same.
fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 {
let [w, h] = doc_size;
let d = xf.to_doc(p);
if w == 0 || h == 0 || !(d[0].is_finite() && d[1].is_finite()) {
return p;
}
let c = [d[0].clamp(0.0, f64::from(w)), d[1].clamp(0.0, f64::from(h))];
if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) }
}
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) {
let before = xf.to_doc(p);
view.zoom = new_zoom;
@@ -3663,9 +3734,21 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate
pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) {
let dash = 4.0f32;
let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32;
let white = Stroke::new(1.0, Color32::WHITE);
let black = Stroke::new(1.0, Color32::BLACK);
let clip = painter.clip_rect();
// Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a
// line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen
// whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted
// segment (a transformed outline) stays a line shape, which egui antialiases.
let mut mesh = egui::Mesh::default();
// A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line).
let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| {
let i = mesh.vertices.len() as u32;
for p in [a + side, a - side, b - side, b + side] {
mesh.colored_vertex(p, color);
}
mesh.add_triangle(i, i + 1, i + 2);
mesh.add_triangle(i, i + 2, i + 3);
};
for (a, b) in segs {
let pa = to_screen(*a);
let pb = to_screen(*b);
@@ -3674,12 +3757,19 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
}
let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5);
let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5);
painter.line_segment([pa, pb], white);
let len = pa.distance(pb);
if len < 0.5 {
continue;
}
let dir = (pb - pa) / len;
let side = dir.rot90() * 0.5;
let straight = pa.x == pb.x || pa.y == pb.y;
if straight {
// The white base runs half a pixel past both ends, so joined segments meet at corners.
quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE);
} else {
painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE));
}
// Phase by screen position so dashes line up across joined segments.
let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0);
let mut t = -start;
@@ -3687,11 +3777,18 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
let s0 = t.max(0.0);
let s1 = (t + dash).min(len);
if s1 > s0 {
painter.line_segment([pa + dir * s0, pa + dir * s1], black);
if straight {
quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK);
} else {
painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK));
}
}
t += dash * 2.0;
}
}
if !mesh.is_empty() {
painter.add(egui::Shape::mesh(mesh));
}
}
#[allow(dead_code)]
@@ -5031,7 +5128,10 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(st) = app.session.documents().get(w.document) else { continue };
let title = format!("{} — window {}", st.doc.name, w.id);
let vid = egui::ViewportId::from_hash_of(("docwin", w.id));
let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
if let Some([x, y]) = w.position {
builder = builder.with_position([x, y]);
}
let mut view = w.view.clone();
let mut close = false;
ctx.show_viewport_immediate(vid, builder, |ui, _class| {
@@ -5688,6 +5788,49 @@ mod tests {
}
}
/// Dragging a tab off the strip floats its document in its own OS window instead of
/// reordering (#3733), in both strips; a short wobble below the strip still reorders.
#[test]
fn dragging_a_tab_off_the_strip_floats_it_in_a_window() {
use egui_kittest::kittest::Queryable;
let strip = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(700.0, 26.0));
assert!(!torn_off(strip, egui::pos2(50.0, 13.0)));
assert!(!torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF - 1.0)), "a wobble below the strip still reorders");
assert!(torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF + 1.0)));
assert!(torn_off(strip, egui::pos2(50.0, -TEAR_OFF - 1.0)), "dragged up, off the window's top");
for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for name in ["a", "b", "c"] {
app.run("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
}
app.sync_views();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 300.0)).build_ui_state(
|ui, (app, ready): &mut (PhotocraftApp, bool)| {
if *ready {
tabs(app, ui);
}
},
(app, false),
);
PhotocraftApp::setup_context(&h.ctx, kind);
h.state_mut().1 = true;
h.run_steps(2);
let b = h.get_by_label_contains("b").rect();
let target = egui::pos2(b.center().x, b.bottom() + 150.0);
h.drag_at(b.center());
h.run_steps(1);
h.hover_at(target);
h.run_steps(1);
h.drop_at(target);
h.run_steps(2);
let app = &h.state().0;
let names: Vec<&str> = app.session.documents().iter().map(|d| d.doc.name.as_str()).collect();
assert_eq!(names, ["a", "b", "c"], "{kind:?}: tearing off doesn't reorder");
assert_eq!(app.ui.windows.iter().map(|w| w.document).collect::<Vec<_>>(), [1], "{kind:?}: b floats in its own window");
assert_eq!(app.session.active_index(), Some(1), "{kind:?}: the floated document is active");
}
}
#[test]
fn clone_overlay_is_circular_and_respects_visibility() {
let ctx = egui::Context::default();
@@ -6687,6 +6830,23 @@ mod tests {
}
}
#[test]
fn wheel_anchor_is_the_pointer_over_the_image_and_the_nearest_image_point_off_it() {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
for (rotation, flip) in [(0.0, false), (30.0, false), (90.0, true)] {
let xf = ViewXform { rect, zoom: 0.5, center: [400.0, 300.0], flip, rotation, aspect: 1.0 };
let inside = xf.to_screen(100.0, 200.0);
assert_eq!(wheel_anchor(&xf, inside, [800, 600]), inside);
for (p, want) in [((-300.0, -200.0), [0.0, 0.0]), ((1200.0, 300.0), [800.0, 300.0]), ((400.0, 900.0), [400.0, 600.0])] {
let a = xf.to_doc(wheel_anchor(&xf, xf.to_screen(p.0, p.1), [800, 600]));
assert!((a[0] - want[0]).abs() < 0.01 && (a[1] - want[1]).abs() < 0.01, "rot {rotation}: {p:?} -> {a:?}");
}
}
let p = pos2(5.0, 5.0);
let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 };
assert_eq!(wheel_anchor(&xf, p, [0, 0]), p, "no document: the pointer");
}
#[test]
fn zoom_about_centres_the_clicked_point_with_the_preference() {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
+6 -1
View File
@@ -174,7 +174,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
let mut job = egui::text::LayoutJob::default();
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
// Built-in labels only: alpha/spot names and "{layer} Mask" stay verbatim.
let label = match row {
Row::Composite | Row::Color(_) | Row::QuickMask => tl!(&name).to_owned(),
Row::Alpha(_) | Row::LayerMask => name.clone(),
};
job.append(&label, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
let galley = painter.layout_job(job);
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
painter.galley(text_pos, galley, t.text);
+2 -1
View File
@@ -540,7 +540,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
});
ui.add_space(8.0);
let mut inv = flag(f, "invert");
if widgets::checkbox(ui, &mut inv, tl!("Invert")).changed() {
// Selection invert (≠ Image › Adjustments › Invert → Đảo màu).
if widgets::checkbox(ui, &mut inv, crate::i18n::tr_ctx(crate::i18n::current(), "colorRange", "Invert")).changed() {
f.insert("invert".into(), json!(inv));
}
});
+85
View File
@@ -820,4 +820,89 @@ mod tests {
}
}
}
/// #1919: with Delete Cropped Pixels off, a second crop's handle dragged out toward the hidden
/// pixels snaps exactly onto their edge (a layer edge), however far from the handle's centre
/// the press grabbed it, in both modes, with and without Auto Center Preview: the committed
/// crop has no transparent strip.
#[test]
fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() {
// The image 200 × 100 cropped to [40, 20, 140, 70]: its pixels stay at [-40, -20, 160, 80].
let hidden = [-40.0, -20.0, 160.0, 80.0];
for classic in [false, true] {
for auto in [true, false] {
for (hx, hy) in [(1i8, 0i8), (0, -1), (-1, -1), (1, 1)] {
// A corner also with the image's 2:1 ratio held, which both hidden corners keep.
for (grab, ratio) in [(-4.0f32, false), (0.0, false), (4.0, false), (-4.0, true), (4.0, true)] {
if ratio && (hx == 0 || hy == 0) {
continue;
}
let what = format!("Classic Mode {classic}, Auto Center {auto}, handle ({hx}, {hy}), grabbed {grab} px off, ratio {ratio}");
let mut app = app();
app.ui.tool_options.crop_delete = false;
app.ui.tool_options.crop_shield.classic_mode = classic;
app.ui.tool_options.crop_shield.auto_center_preview = auto;
crate::crop_ui::ensure_frame(&mut app);
app.ui.crop_rect = Some([40.0, 20.0, 140.0, 70.0]);
app.crop.default_frame = false;
crate::canvas::commit_crop(&mut app);
let v = &mut app.ui.views[0];
(v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0);
app.ui.extras.snap = true;
crate::crop_ui::ensure_frame(&mut app);
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0]));
if ratio {
app.ui.tool_options.crop_ratio = "original".into();
}
let grab = vec2(f32::from(hx), f32::from(hy)) * grab;
let from = xf(&app).to_screen(50.0 + f32::from(hx) * 50.0, 25.0 + f32::from(hy) * 25.0) + grab;
// Toward the hidden corner (or edge), a screen pixel at a time on the longer axis.
let gap = vec2(f32::from(hx) * if hx < 0 { 40.0 } else { 60.0 }, f32::from(hy) * if hy < 0 { 20.0 } else { 30.0 });
let dir = gap / gap.x.abs().max(gap.y.abs());
let d = xf(&app).to_doc(from);
tool_event(&mut app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, NONE);
// Drag outward a screen pixel at a time; let go once the moving edges show
// the snap to the hidden pixels' edges (as the user would).
let snapped = |app: &PhotocraftApp| {
let on = |vertical: bool, pos: f64| {
app.prefs_rt
.snap_lines
.iter()
.any(|l| l.vertical == vertical && l.pos == pos && l.kind == photocraft_engine::snap::SnapKind::LayerEdge)
};
(hx == 0 || on(true, if hx < 0 { hidden[0] } else { hidden[2] }))
&& (hy == 0 || on(false, if hy < 0 { hidden[1] } else { hidden[3] }))
};
let mut end = None;
for i in 1..=80 {
let d = xf(&app).to_doc(from + dir * i as f32);
tool_event(&mut app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, NONE);
if snapped(&app) {
end = Some(from + dir * i as f32);
break;
}
}
let end = end.unwrap_or_else(|| panic!("{what}: never snapped to the hidden edge"));
// Released where it stopped, read through the view as it is now.
let end = xf(&app).to_doc(end);
tool_event(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, NONE);
let pick = |h: i8, lo: f64, hi: f64, a: f64, b: f64| match h {
-1 => [a, hi],
1 => [lo, b],
_ => [lo, hi],
};
let [x0, x1] = pick(hx, 0.0, 100.0, hidden[0], hidden[2]);
let [y0, y1] = pick(hy, 0.0, 50.0, hidden[1], hidden[3]);
assert_eq!(app.ui.crop_rect, Some([x0, y0, x1, y1]), "{what}: the frame lands exactly on the hidden edge");
crate::canvas::commit_crop(&mut app);
let st = app.session.active().unwrap();
assert_eq!((st.doc.size.width, st.doc.size.height), ((x1 - x0) as u32, (y1 - y0) as u32), "{what}");
let layer = st.doc.walk().into_iter().map(|(_, _, l)| l.id).next().unwrap();
let r = photocraft_engine::snap::layer_rect(&st.doc, layer).unwrap();
assert_eq!(r, [hidden[0] - x0, hidden[1] - y0, hidden[2] - x0, hidden[3] - y0], "{what}: no transparent strip");
}
}
}
}
}
}
+4 -3
View File
@@ -3,15 +3,16 @@
//! Rulers & Grids toggles. Every edit dispatches an engine or view command, so the panel stays thin.
use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use photocraft_doc::{ColorMode, Document, Layer, LayerContent, LayerId, SampleType};
use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{PhotocraftApp, icons, widgets};
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack.
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack,
/// whatever its (localized) name ([`Document::background`]).
pub fn is_background(doc: &Document, l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id)
doc.is_background(l.id)
}
/// Does the Properties panel show the Document view? (No layer selected, or the Background layer.)
+4
View File
@@ -765,6 +765,10 @@ pub fn restore(app: &mut PhotocraftApp) {
}
let saved = app.session.prefs().panel_layout.clone();
apply(app, &saved);
// The floating Brush Settings window is transient, not a dock panel: remembering the
// workspace must not leave it open on a fresh launch, especially with no document.
// Explicit workspace switches still use `apply` and keep their stored panel visibility.
app.ui.panels.brush_settings = false;
if let Some(ws) = saved.get("workspace").and_then(Value::as_str) {
app.ui.workspace = ws.to_string();
}
+28
View File
@@ -1082,3 +1082,31 @@ fn a_tab_dragged_off_the_strip_moves_the_group_and_locking_stops_reordering() {
drag(&mut h, paths.center(), paths.center() + vec2(0.0, 500.0));
assert_eq!(h.state().ui.dock.order(), order);
}
#[test]
fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() {
let mut first = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
first.ui.panels.brush_settings = true;
first.ui.panels.history = true;
let saved = snapshot(&first);
first.session.prefs.edit(|p| {
p.workspace.remember_workspace_changes = true;
p.panel_layout = saved.clone();
});
let persisted = first.session.prefs_to_json();
let mut session = photocraft_engine::Session::new();
session.load_prefs_json(&persisted).unwrap();
let mut next = PhotocraftApp::new(session, crate::Services::default());
restore(&mut next);
assert!(!next.ui.panels.brush_settings, "a floating Brush Settings window should not reopen on startup");
assert!(next.ui.panels.history, "remembered dock panel visibility is preserved");
let ctx = egui::Context::default();
crate::menus::invoke(&mut next, &ctx, "window.toggle.brushSettings", json!({})).unwrap();
assert!(next.ui.panels.brush_settings, "Window > Brush Settings can still open it");
let mut workspace = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
apply(&mut workspace, &saved);
assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout");
}
+19 -2
View File
@@ -21,12 +21,14 @@ use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::file_open::display_name;
/// What a file dialog asks the user for.
/// What a file or folder dialog asks the user for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialogRequest {
/// Files to read: several for File › Open, one for a command that reads a file (Place,
/// scripts, notes, presets). Starts in `initial_dir` (the last-used folder, UI-217-3).
Open { multiple: bool, initial_dir: Option<String>, extensions: Option<Vec<String>> },
/// Which folder to read from. Starts in `initial_dir` when available.
Folder { initial_dir: Option<String> },
/// Where to write, starting from `suggested` (a file name, or the document's own path).
Save { suggested: String },
}
@@ -42,6 +44,8 @@ pub enum FileDialogAnswer {
ContentsMany(Vec<(String, Vec<u8>)>),
/// The path to save to (desktop), or the download's name (web).
SaveTo(String),
/// The selected folder's path (desktop).
FolderTo(String),
/// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message
/// says what to install. Reported as an error rather than taken for Cancel.
Unavailable(String),
@@ -88,7 +92,7 @@ impl PhotocraftApp {
}
/// Show a file dialog; `then` continues with the user's choice on a later frame (see the
/// module docs). Returns at once with `{"fileDialog": "open" | "save"}`; `Err` without a
/// module docs). Returns at once with `{"fileDialog": "open" | "folder" | "save"}`; `Err` without a
/// dialog service ("cancelled", as before there were async dialogs) or while one is open.
pub(crate) fn ask_file(
&mut self,
@@ -103,6 +107,7 @@ impl PhotocraftApp {
}
let kind = match request {
FileDialogRequest::Open { .. } => "open",
FileDialogRequest::Folder { .. } => "folder",
FileDialogRequest::Save { .. } => "save",
};
let then: Then = Box::new(move |app, answer| match answer {
@@ -114,6 +119,16 @@ impl PhotocraftApp {
Ok(json!({ "fileDialog": kind }))
}
/// Ask the user to choose a folder, starting in the folder of the most recent file when known.
/// `then` receives the selected path when the dialog is answered.
pub(crate) fn ask_folder(&mut self, then: impl FnOnce(&mut Self, String) -> Result<Value, String> + 'static) -> Result<Value, String> {
let initial_dir = last_used_dir(&self.ui.recent_files);
self.ask_file(FileDialogRequest::Folder { initial_dir }, move |app, answer| match answer {
FileDialogAnswer::FolderTo(path) => then(app, path),
_ => Err(UNEXPECTED.into()),
})
}
/// Ask where to save: `then` gets the chosen path, with a lowercase extension when
/// Preferences ▸ File Handling › Lowercase Extension is on (its default).
///
@@ -193,6 +208,7 @@ impl PhotocraftApp {
}
FileDialogAnswer::Contents(name, bytes) => app.open_contents(vec![(name, bytes)]),
FileDialogAnswer::ContentsMany(files) => app.open_contents(files),
FileDialogAnswer::FolderTo(_) => return Err(UNEXPECTED.into()),
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => return Err(UNEXPECTED.into()),
}
Ok(Value::Null)
@@ -290,6 +306,7 @@ fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec<u8>), String> {
let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?;
Ok((path, bytes))
}
FileDialogAnswer::FolderTo(_) => Err(UNEXPECTED.into()),
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => Err(UNEXPECTED.into()),
}
}
+24
View File
@@ -175,6 +175,30 @@ fn open_starts_in_the_last_used_folder() {
);
}
#[test]
fn ask_folder_uses_the_async_dialog_and_returns_the_selected_path() {
let (mut app, open, _) = app();
let ctx = egui::Context::default();
app.ui.recent_files = vec!["/pics/cat.psd".into()];
let selected = Rc::new(RefCell::new(None));
let result = selected.clone();
assert_eq!(
app.ask_folder(move |_, path| {
*result.borrow_mut() = Some(path);
Ok(Value::Null)
})
.unwrap(),
json!({"fileDialog": "folder"})
);
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { initial_dir: Some(dir) }, _)] if dir == "/pics"));
answer(&open, Some(FileDialogAnswer::FolderTo("/pics/frames".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(selected.borrow().as_deref(), Some("/pics/frames"));
}
#[test]
fn filtered_picker_requests_only_the_given_extensions() {
let (mut app, open, _) = app();
+2 -1
View File
@@ -616,7 +616,8 @@ fn views_and_windows_stay_with_their_documents() {
for (i, v) in app.ui.views.iter_mut().enumerate() {
v.zoom = i as f32 + 1.0;
}
app.ui.windows = (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true }).collect();
app.ui.windows =
(0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true, position: None }).collect();
let zooms = |app: &PhotocraftApp| app.ui.views.iter().map(|v| v.zoom).collect::<Vec<_>>();
let windows = |app: &PhotocraftApp| app.ui.windows.iter().map(|w| (w.id, w.document)).collect::<Vec<_>>();
assert_eq!(app.run("document.move", json!({"document": 0, "to": 3})).unwrap(), json!({"document": 3}));
+16 -4
View File
@@ -1093,12 +1093,12 @@ mod tests {
assert!(!has_dialog("filter.stylize.findEdges"));
}
#[test]
fn korean_covers_generated_filter_options_and_gallery_names() {
let ko = crate::i18n::lang_from_tag("ko-KR").unwrap();
/// Generated filter and adjustment labels (param names, symbolic choices) and gallery
/// names that `lang` has no catalog row for.
fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet<String> {
let mut missing = std::collections::BTreeSet::new();
let mut check = |s: String| {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
missing.insert(s);
}
};
@@ -1125,9 +1125,21 @@ mod tests {
for cat in photocraft_algo::GALLERY_CATEGORIES {
check(cat.to_string());
}
missing
}
#[test]
fn korean_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap());
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
}
#[test]
fn italian_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap());
assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}");
}
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
/// params, choices and title in every UI language, not just Korean.
#[test]
File diff suppressed because it is too large. Load diff
+11 -3
View File
@@ -10,9 +10,17 @@ fn lang(code: &str) -> Lang {
#[test]
fn requested_languages_resolve_regional_preferences() {
for (tag, code) in
[("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr"), ("id-ID", "id")]
{
for (tag, code) in [
("en-US", "en"),
("zh-CN", "zh-hans"),
("ja-JP", "ja"),
("ko-KR", "ko"),
("ru-RU", "ru"),
("uk-UA", "uk"),
("fr-CA", "fr"),
("id-ID", "id"),
("vi-VN", "vi"),
] {
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
}
assert_eq!(lang_from_tag("ko_KR.UTF-8"), Some(lang("ko")));
+15 -1
View File
@@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 17] = [
pub static LANGUAGES: [LangInfo; 18] = [
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
@@ -164,6 +164,8 @@ pub static LANGUAGES: [LangInfo; 17] = [
// Dutch; `nl-NL` and `nl-BE` locales both resolve here.
LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
// Vietnamese; `vi-VN` resolves here.
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
];
impl LangInfo {
@@ -379,6 +381,7 @@ mod tests {
const ZH: fn() -> Lang = || Lang::from_code("zh-hant").expect("zh-hant registered");
const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered");
const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
#[test]
fn indonesian_tags_resolve() {
@@ -388,6 +391,17 @@ mod tests {
assert_eq!(ID().name(), "Bahasa Indonesia");
}
#[test]
fn vietnamese_tags_resolve() {
for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] {
assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}");
}
assert_eq!(VI().name(), "Tiếng Việt");
assert!(VI().complete_menus(), "Vietnamese translates every source key");
assert_eq!(Lang::from_pref("vi"), VI());
assert_eq!(Lang::from_pref("VI"), VI());
}
#[test]
fn tags_map_to_languages() {
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA()));
File diff suppressed because it is too large. Load diff
+59
View File
@@ -707,3 +707,62 @@ fn escape_with_targa_options_open_does_not_deselect() {
h.run_steps(2);
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to Targa Options");
}
/// How much of the 400 × 300 document is on screen: the visible document span on each axis,
/// clipped to the image (0 when the image has left the view on that axis).
fn visible_image(h: &Harness<'static, PhotocraftApp>) -> [f32; 2] {
let r = h.state().last_canvas_rect;
let (a, b) = (doc_at(h, r.min), doc_at(h, r.max));
[(b[0].min(400.0) - a[0].max(0.0)).max(0.0), (b[1].min(300.0) - a[1].max(0.0)).max(0.0)]
}
#[test]
fn wheel_zoom_over_the_pasteboard_keeps_the_image_in_view() {
// #3720: zooming in with the pointer on the gray pasteboard anchored on the pointer, so a few
// notches pushed the image out of the window. The anchor is now the nearest point of the image.
for (zoom_with_wheel, m) in [(true, Modifiers::NONE), (false, Modifiers::ALT)] {
let mut h = harness();
h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel);
let r = h.state().last_canvas_rect;
let p = pos2(r.left() + 20.0, r.top() + 20.0);
let d = doc_at(&h, p);
assert!(d[0] < 0.0 && d[1] < 0.0, "the pointer starts on the pasteboard: {d:?}");
h.hover_at(p);
h.run_steps(2);
let z0 = zoom(&h);
for _ in 0..20 {
wheel(&h, 1.0, m);
h.run_steps(3);
}
h.run_steps(20);
assert!(zoom(&h) > z0 * 5.0, "the notches zoomed in: {z0} -> {}", zoom(&h));
let v = visible_image(&h);
assert!(v[0] > 0.0 && v[1] > 0.0, "zoom_with_wheel {zoom_with_wheel}: the image left the view {v:?}");
// The image's corner nearest the pointer is the anchor: it stays where it was on screen.
let c = doc_at(&h, pos2(r.left() + 20.0, r.top() + 20.0));
assert!(c[0] < 0.0 && c[1] < 0.0, "still pasteboard under the pointer, the corner held: {c:?}");
// A pinch from there behaves the same.
h.event(egui::Event::Zoom(4.0));
h.run_steps(3);
let v = visible_image(&h);
assert!(v[0] > 0.0 && v[1] > 0.0, "pinch: the image left the view {v:?}");
}
}
#[test]
fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() {
let mut h = harness();
let r = h.state().last_canvas_rect;
let p = pos2(r.center().x - 150.0, r.center().y - 100.0);
h.hover_at(p);
h.run_steps(2);
let d0 = doc_at(&h, p);
assert!(d0[0] > 0.0 && d0[1] > 0.0);
for _ in 0..5 {
wheel(&h, 1.0, Modifiers::ALT);
h.run_steps(3);
}
h.run_steps(20);
let d1 = doc_at(&h, p);
assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}");
}
+26
View File
@@ -383,6 +383,32 @@ fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() {
assert!(sel.sample_channel(120, 110, 0) < 0.01 && sel.sample_channel(90, 110, 0) > 0.99, "subtracted, not centred");
}
/// #208: `⇧⌥` held at the press intersects, the third selection mode after add (`⇧`) and subtract
/// (`⌥`). Like those, the modifier at the press picks the mode; here the keys are released once the
/// press has fixed the mode, so the drag stays a plain rectangle and only the overlap of the
/// existing selection and the dragged rectangle survives. The three sample points below are each in
/// exactly one of {overlap, first-only, drag-only}, so they pass only for intersect, not for
/// replace, add or subtract.
#[test]
fn shift_alt_before_the_drag_intersects_the_selection() {
let mut h = harness(Tool::RectMarquee);
// A: the existing selection, 50..200 on both axes.
h.state_mut().run("select.rect", json!({"x": 50, "y": 50, "width": 150, "height": 150})).unwrap();
let both = Modifiers::SHIFT | Modifiers::ALT;
mods(&mut h, both);
press_at(&mut h, 120.0, 120.0, both);
// Mode is fixed at the press; releasing the keys keeps intersect mode but leaves the drag a
// plain rectangle (no square/centre constraint), so B is roughly 120..300 on both axes.
mods(&mut h, Modifiers::NONE);
move_to(&mut h, 300.0, 300.0);
release_at(&mut h, 300.0, 300.0, Modifiers::NONE);
let st = h.state().session.active().unwrap();
let sel = st.doc.selection.as_ref().expect("a selection survives the intersect");
assert!(sel.sample_channel(160, 160, 0) > 0.99, "the overlap of the two rectangles stays selected");
assert!(sel.sample_channel(60, 60, 0) < 0.01, "a point only in the existing selection is dropped (not add)");
assert!(sel.sample_channel(260, 260, 0) < 0.01, "a point only in the dragged rectangle is dropped (not replace)");
}
/// `cargo test --release -p photocraft-ui-egui marquee_drag_bench -- --ignored --nocapture`
#[test]
#[ignore]
+46
View File
@@ -11,6 +11,52 @@ const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.
/// The tallest menus.
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
/// #3737: on Wayland egui knows the monitor's size but never where the window is (no
/// `inner_rect`). A window taller than the monitor less its taskbar or panel runs under it, so
/// Layer, View and Window (and every other long menu) must end above it and scroll to their last
/// row, at 1× and 1.5×.
#[test]
fn reported_menus_stay_above_the_panel_when_the_window_position_is_unknown() {
for scale in [1.0, 1.5] {
for top in ["Layer", "View", "Window", "Filter", "Image", "Edit"] {
let mut h = harness((1366.0 * scale, 740.0 * scale, scale));
let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default();
v.monitor_size = Some(egui::vec2(1366.0, 768.0));
v.inner_rect = None;
v.outer_rect = None;
v.maximized = Some(false);
h.run_steps(3);
let bottom = 768.0 - crate::work_area::TASKBAR;
let what = format!("{scale}x {top}");
open(&mut h, top);
for r in popups(&h) {
assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})");
}
check_bar_clear(&h, &what);
let last = rows(&h, 0).len() - 1;
let in_view = |h: &Harness<'static, PhotocraftApp>| {
let nav = Nav::current(&h.ctx);
nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5
};
let view = Nav::current(&h.ctx).views[0];
h.hover_at(view.center());
for _ in 0..80 {
if in_view(&h) {
break;
}
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.run_steps(2);
}
assert!(in_view(&h), "{what}: the wheel reaches the last row, above the panel");
}
}
}
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
+4 -1
View File
@@ -292,6 +292,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
app.open_dialog_file()
}
}
"file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => {
app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string()))
}
"file.save" => {
// Writes back only to a layered file; a flat one goes through Save As.
let tga_bits = crate::targa_options_ui::bits_param(&params, id)?;
@@ -340,7 +343,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
let doc = app.session.active_index().ok_or("no document")?;
let wid = app.ui.alloc_id();
let view = app.ui.views[doc].clone();
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
Ok(json!({"window": wid}))
}
"window.theme.toggle" => {
@@ -182,3 +182,31 @@ fn every_theme_shows_the_type_options() {
assert!(combos.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; combo boxes: {combos:?}");
}
}
#[test]
fn every_theme_shows_the_gradient_options() {
// #3601: the Studio themes' options bar was only the hint "Drag to draw a gradient", with no
// mode, style, blend mode or opacity control.
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
for theme in crate::theme::ThemeKind::ALL {
let (mut app, _, _) = app();
app.ui.tool = Tool::Gradient;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::options_bar(app, ui);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(4);
let combos: Vec<String> = h
.query_all_by_role(egui::accesskit::Role::ComboBox)
.map(|n| format!("{:?} {:?}", n.accesskit_node().label(), n.accesskit_node().value()))
.collect();
assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}");
}
}
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more