Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 9ea1268cac fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info)
- TySh: the four values after the warp descriptor are big-endian f32, not
  i32. Written as Photoshop writes them: zeros for unwarped type, the
  text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
  text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
  read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
  3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
  no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
  no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
  write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:45:15 -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
78 changed files with 6780 additions and 196 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
+1
View File
@@ -4383,6 +4383,7 @@ name = "photocraft-format"
version = "0.7.0"
dependencies = [
"blake3",
"crc32fast",
"flate2",
"photocraft-codecs",
"photocraft-color",
+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
+2
View File
@@ -382,6 +382,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
+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();
}
+60 -21
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) {
@@ -2883,6 +2877,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);
+59 -17
View File
@@ -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);
@@ -980,6 +986,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();
+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();
+9 -7
View File
@@ -1152,25 +1152,27 @@ 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);
}
+17 -4
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
+27 -11
View File
@@ -19,9 +19,16 @@ 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 +41,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);
@@ -97,17 +105,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);
}
+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()));
+35 -2
View File
@@ -1435,10 +1435,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 +1719,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);
}
}
+5 -2
View File
@@ -808,6 +808,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 +821,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)
+2 -2
View File
@@ -112,8 +112,8 @@ fn tysh_with_font(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transf
.with("boundingBox", rect("boundingBox"))
.with("TextIndex", D::Integer(0))
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds, font))));
let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32];
write_tysh(&TySh { transform, text: desc, warp: None, bounds })
// Unwarped type: Photoshop writes four zero f32s after the warp descriptor.
write_tysh(&TySh { transform, text: desc, warp: None, bounds: [0.0; 4] })
}
struct Case {
@@ -0,0 +1,74 @@
//! Type-layer bytes we write match what Photoshop 2026 writes for the same settings, checked
//! against Photoshop-authored files in `corpus/photoshop` (feature `corpus`; fetched by
//! `cargo xtask corpus --all`, a missing corpus fails):
//! - EngineData `AntiAlias` numbers for every anti-aliasing mode;
//! - the four f32 values that end a `TySh` block (zeros unwarped, text-space bounds warped);
//! - the version info resource (1057).
#![cfg(feature = "corpus")]
use std::path::{Path, PathBuf};
use photocraft_psd::PsdFile;
use photocraft_psd::descriptor::Value as D;
use photocraft_text::engine_data::Value as E;
fn corpus(rel: &str) -> PsdFile {
let path: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop").join(rel);
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display()));
PsdFile::from_bytes(&bytes).unwrap()
}
fn first_tysh(f: &PsdFile) -> Vec<u8> {
f.layers().iter().find_map(|l| l.block(b"TySh")).map(|b| b.data.clone()).unwrap()
}
fn engine_antialias(tysh: &[u8]) -> Option<f64> {
let t = photocraft_text::psd::parse_tysh(tysh)?;
photocraft_text::psd::engine_data(&t.text)?.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)
}
fn rect(t: &photocraft_text::psd::TySh, key: &str) -> [f32; 4] {
let Some(D::Descriptor(d)) = t.text.get(key) else { panic!("no {key}") };
["Left", "Top ", "Rght", "Btom"].map(|k| match d.get(k) {
Some(D::UnitFloat { value, .. }) => *value as f32,
other => panic!("{k}: {other:?}"),
})
}
#[test]
fn engine_data_antialias_matches_photoshop() {
for name in ["none", "crisp", "strong", "smooth", "sharp"] {
let raw = first_tysh(&corpus(&format!("text/antialias-{name}.psd")));
let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
layer.psd_raw = None;
let ours = photocraft_text::psd::build_tysh(&layer, 72.0, None);
assert_eq!(engine_antialias(&ours), engine_antialias(&raw), "{name}");
}
}
#[test]
fn tysh_trailing_bounds_match_photoshop() {
for (file, warped) in [("text/warp-arc-50.psd", true), ("text/antialias-smooth.psd", false)] {
let raw = first_tysh(&corpus(file));
let theirs = photocraft_text::psd::parse_tysh(&raw).unwrap();
let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
assert_eq!(layer.warp.is_some(), warped, "{file}");
layer.psd_raw = None;
let ours = photocraft_text::psd::parse_tysh(&photocraft_text::psd::build_tysh(&layer, 72.0, Some(rect(&theirs, "bounds")))).unwrap();
assert_eq!(ours.bounds, theirs.bounds, "{file}");
if warped {
assert_eq!(theirs.bounds, rect(&theirs, "bounds"), "{file}");
} else {
assert_eq!(theirs.bounds, [0.0; 4], "{file}");
}
}
}
#[test]
fn version_info_matches_photoshop() {
for file in ["text/antialias-smooth.psd", "text/warp-arc-50.psd"] {
let theirs = corpus(file);
assert_eq!(photocraft_psd::resources::version_info_resource(true).data, theirs.resource(1057).unwrap().data, "{file}");
}
}
+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);
+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`]).
+17 -3
View File
@@ -250,15 +250,16 @@ pub(crate) fn write_section(out: &mut Vec<u8>, res: &[ImageResource]) -> Result<
out.end_len(at, false)
}
/// Builds the data for a 1057 version info resource.
/// Builds the data for a 1057 version info resource, with the writer and reader names Photoshop
/// 2026 writes ("Adobe Photoshop", "Adobe Photoshop 2026"), without a NUL terminator.
pub fn version_info_resource(has_real_merged_data: bool) -> ImageResource {
ImageResource::new(
ids::VERSION_INFO,
VersionInfo {
version: 1,
has_real_merged_data,
writer: UnicodeString::new_nul("Adobe Photoshop"),
reader: UnicodeString::new_nul("Adobe Photoshop CS6"),
writer: UnicodeString::new("Adobe Photoshop"),
reader: UnicodeString::new("Adobe Photoshop 2026"),
file_version: 1,
}
.to_bytes(),
@@ -365,4 +366,17 @@ mod tests {
other => panic!("{other:?}"),
}
}
/// Byte for byte what Photoshop 2026 writes (every Photoshop-authored oracle in the corpus):
/// u32 count + UTF-16BE, no NUL terminator.
#[test]
fn version_info_matches_photoshop_bytes() {
let mut want = vec![0, 0, 0, 1, 1];
for s in ["Adobe Photoshop", "Adobe Photoshop 2026"] {
want.extend_from_slice(&(s.len() as u32).to_be_bytes());
want.extend(s.encode_utf16().flat_map(u16::to_be_bytes));
}
want.extend_from_slice(&[0, 0, 0, 1]);
assert_eq!(version_info_resource(true).data, want);
}
}
+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());
}
+34 -12
View File
@@ -24,8 +24,10 @@ pub struct TySh {
pub transform: Affine,
pub text: Descriptor,
pub warp: Option<Descriptor>,
/// The four trailing i32 values (left, top, right, bottom).
pub bounds: [i32; 4],
/// The four trailing big-endian f32 values (left, top, right, bottom) after the warp
/// descriptor. Photoshop writes zeros for unwarped type and the text-space bounds (the
/// `bounds` descriptor's rectangle) for warped type.
pub bounds: [f32; 4],
}
/// Parses a `TySh` block.
@@ -42,10 +44,10 @@ pub fn parse_tysh(data: &[u8]) -> Option<TySh> {
None => (None, 0),
};
let tail = rest.get(wused..).unwrap_or(&[]);
let mut bounds = [0i32; 4];
let mut bounds = [0f32; 4];
for (i, b) in bounds.iter_mut().enumerate() {
if let Some(x) = tail.get(i * 4..i * 4 + 4) {
*b = i32::from_be_bytes(x.try_into().unwrap_or([0; 4]));
*b = f32::from_be_bytes(x.try_into().unwrap_or([0; 4]));
}
}
Some(TySh { transform: Affine { m }, text: text.descriptor, warp, bounds })
@@ -221,7 +223,11 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
Some("AnSt" | "antiAliasStrong") => AntiAlias::Strong,
Some("antiAliasPlatformGray") => AntiAlias::Windows,
Some("antiAliasPlatformLCD") => AntiAlias::WindowsLcd,
_ => AntiAlias::Smooth,
Some(_) => AntiAlias::Smooth,
// No `AntA`: the engine data's mode says it.
None => {
engine_data(&t.text).and_then(|e| e.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)).and_then(engine_antialias).unwrap_or(AntiAlias::Smooth)
}
};
if let Some(w) = &t.warp {
let style = enum_value(w, "warpStyle").unwrap_or_else(|| "warpNone".into());
@@ -979,12 +985,13 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
"AntiAlias",
E::Int(match layer.antialias {
AntiAlias::None => 0,
AntiAlias::Sharp => 1,
AntiAlias::Crisp => 2,
AntiAlias::Strong => 3,
AntiAlias::Smooth => 4,
// EngineData has no platform modes; the `AntA` descriptor carries the exact value.
AntiAlias::Windows | AntiAlias::WindowsLcd => 1,
AntiAlias::Crisp => 1,
AntiAlias::Strong => 2,
AntiAlias::Smooth => 3,
AntiAlias::Sharp => 4,
// No Photoshop file confirms the platform modes' numbers here; the `AntA` descriptor
// carries the exact mode, and Smooth is the closest.
AntiAlias::Windows | AntiAlias::WindowsLcd => 3,
}),
);
if dict.get("UseFractionalGlyphWidths").is_none() {
@@ -1059,6 +1066,19 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
e
}
/// The anti-aliasing mode for EngineData's `AntiAlias` number, as Photoshop writes it: 0 None,
/// 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Other numbers (platform modes) are left to `AntA`.
fn engine_antialias(n: f64) -> Option<AntiAlias> {
Some(match n as i64 {
0 => AntiAlias::None,
1 => AntiAlias::Crisp,
2 => AntiAlias::Strong,
3 => AntiAlias::Smooth,
4 => AntiAlias::Sharp,
_ => return None,
})
}
/// Builds the `TySh` block for `layer`, patching its current `psd_raw` when present.
/// `ink` is the layer's rendered bounds in text space (for `bounds`/`boundingBox`).
pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8> {
@@ -1123,6 +1143,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
(None, Some(old)) if enum_value(&old, "warpStyle").as_deref() == Some("warpNone") => Some(old),
_ => None,
};
let bounds = [l.floor() as i32, t.floor() as i32, r.ceil() as i32, b.ceil() as i32];
// Photoshop's trailing bounds: zeros for unwarped type, the text-space bounds when warped.
let warped = warp.as_ref().is_some_and(|w| enum_value(w, "warpStyle").is_some_and(|s| s != "warpNone"));
let bounds = if warped { [l, t, r, b] } else { [0.0; 4] };
write_tysh(&TySh { transform: layer.transform, text, warp, bounds })
}
+40
View File
@@ -1505,3 +1505,43 @@ fn translate_tysh_moves_only_the_translation() {
assert_eq!(crate::psd::parse_tysh(&tysh).unwrap().bounds, back.bounds, "bounds are in text space");
assert!(crate::psd::translate_tysh(&tysh[..40], 1.0, 1.0).is_none(), "a truncated block is refused");
}
/// The four values after the `TySh` warp descriptor are big-endian f32, as Photoshop writes them:
/// zeros for unwarped type, the text-space bounds for warped type.
#[test]
fn tysh_trailing_bounds_are_f32() {
use photocraft_doc::text::TextWarp;
let tail = |b: &[u8]| -> [f32; 4] {
let t = b.get(b.len() - 16..).unwrap();
std::array::from_fn(|i| f32::from_be_bytes(t[i * 4..i * 4 + 4].try_into().unwrap()))
};
let ink = [0.0, -34.316406, 288.90625, 15.058594];
let plain = crate::psd::build_tysh(&point("Type", 20.0), 72.0, Some(ink));
assert_eq!(tail(&plain), [0.0; 4]);
let warped = TextLayer {
warp: Some(TextWarp { style: "warpArc".into(), value: 50.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }),
..point("Type", 20.0)
};
let bytes = crate::psd::build_tysh(&warped, 72.0, Some(ink));
assert_eq!(tail(&bytes), ink);
let parsed = crate::psd::parse_tysh(&bytes).unwrap();
assert_eq!(parsed.bounds, ink);
assert_eq!(crate::psd::write_tysh(&parsed), bytes);
}
/// EngineData's `AntiAlias` numbers as Photoshop writes them, beside the `AntA` descriptor:
/// 0 None, 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Read back from EngineData alone (no `AntA`),
/// every mode survives.
#[test]
fn engine_data_antialias_numbers_match_photoshop() {
use photocraft_doc::text::AntiAlias;
for (mode, n) in [(AntiAlias::None, 0), (AntiAlias::Crisp, 1), (AntiAlias::Strong, 2), (AntiAlias::Smooth, 3), (AntiAlias::Sharp, 4)] {
let t = TextLayer { antialias: mode, ..point("Type", 20.0) };
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
let e = crate::psd::engine_data(&tysh.text).unwrap();
assert_eq!(e.path(&["EngineDict", "AntiAlias"]).and_then(crate::engine_data::Value::as_f64), Some(f64::from(n)), "{mode:?}");
tysh.text.items.retain(|(k, _)| !k.is("AntA"));
let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap();
assert_eq!(back.antialias, mode, "{n}");
}
}
+34 -1
View File
@@ -2779,7 +2779,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)), _) => {
@@ -3557,6 +3559,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;
@@ -6687,6 +6703,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
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();
+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();
+3
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)?;
@@ -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:?}");
}
}
+6 -2
View File
@@ -545,7 +545,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
ui.set_width(260.0);
ui.label(RichText::new(tl!("PRESET DETAILS")).size(11.0).color(t.text_faint));
ui.add_space(4.0);
let mut name = get_s(f, "name", tl!("Untitled-1"));
// Keep the canonical English default in state; only the TextEdit shows a translation.
let raw = get_s(f, "name", "Untitled-1");
let mut name = if raw == "Untitled-1" { tl!("Untitled-1").to_string() } else { raw };
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(250.0).font(egui::FontId::proportional(15.0)));
if r.changed() {
f.insert("name".into(), json!(name));
@@ -584,9 +586,11 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
set_size(f, "height", h, &unit, ppi);
}
ui.add_space(6.0);
small_label(ui, tl!("Orientation"));
// Page orientation (≠ Type › Orientation → Hướng chữ).
small_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "document", "Orientation"));
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
for (icon, portrait) in [("rectangle-vertical", true), ("rectangle-horizontal", false)] {
// Tooltip strings are looked up via `icons::button` → `tl!`; enum/state is aspect swap only.
if icons::button(ui, icon, 24.0, (h > w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() && (h > w) != portrait {
swap_size(f);
}
+7 -1
View File
@@ -178,7 +178,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
for tool in crate::state::Tool::ALL {
if let Some(s) = fuzzy_score(&q, &format!("{} {}", tl!(tool.label()), tool.label())) {
let s = s + exact_bonus(&q, &[tl!(tool.label()).as_ref(), tool.label()]);
hits.push((s + 2, format!("tool:{tool:?}"), tl!(tool.label()).into(), Some(format!("Tool {}", tool.key())), true));
hits.push((
s + 2,
format!("tool:{tool:?}"),
tl!(tool.label()).into(),
Some(format!("{} {}", crate::i18n::tr(lang, "Tool"), tool.key())),
true,
));
}
}
}
+35 -12
View File
@@ -128,9 +128,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
+ chips_height(t.pro, t.round_chips, t.round_chips && double)
+ if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 }
+ 6.0;
// As tall as the tools, so the colour chips sit right under them (Photoshop); a column
// taller than the window scrolls and keeps the chips at the bottom.
egui::ScrollArea::vertical()
.id_salt("toolbar-columns")
.auto_shrink([false, false])
.auto_shrink([false, true])
.max_height((ui.available_height() - footer).max(0.0))
.min_scrolled_height(0.0)
.show(ui, |ui| {
@@ -1157,7 +1159,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous"));
widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers"));
}
Tool::Gradient if t.pro => {
Tool::Gradient => {
// Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels).
let mut classic = app.ui.tool_options.gradient_classic;
if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) {
@@ -1412,7 +1414,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
&[("commit", &crate::shortcuts::pretty("Enter"))],
),
),
Tool::Gradient => hint(ui, tl!("Drag to draw a gradient")),
Tool::PaintBucket => hint(ui, tl!("Click to fill similar colours")),
// Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`.
_ => {}
@@ -1450,10 +1451,10 @@ fn label(ui: &mut egui::Ui, s: &str) {
/// Photoshop's scrubby Opacity/Fill labels: drag horizontally to change the percentage without
/// hitting the small number field. The unique drag ID coalesces every frame into one undo step.
fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32) -> widgets::PopupFieldResponse {
let t = Tokens::get(ui.ctx());
let resp =
ui.add(egui::Label::new(RichText::new(text).color(t.text_dim)).sense(Sense::click_and_drag())).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32, max_w: f32) -> widgets::PopupFieldResponse {
// Elide to the room left in the row (full text on hover): a longer translation shrinks here
// instead of running over the fixed controls beside it (#3580).
let resp = widgets::elided_label_sensed(ui, text, max_w, Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
let key = resp.id.with("percent-label-drag");
if resp.drag_started() {
let stamp = ui.ctx().cumulative_pass_nr();
@@ -2115,7 +2116,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut o = l.opacity * 100.0;
let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
// On a narrow dock the (scrubby) label yields its room to the controls (#2309).
let scrub = if show_opacity_label { scrub_pct_label(ui, opacity_label, &mut o) } else { widgets::PopupFieldResponse::default() };
let scrub = if show_opacity_label {
scrub_pct_label(ui, opacity_label, &mut o, ui.available_width())
} else {
widgets::PopupFieldResponse::default()
};
if field.changed || scrub.changed {
actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag)));
}
@@ -2126,7 +2131,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
ui.horizontal(|ui| {
let lock_label = if t.pro { tl!("Lock:") } else { tl!("Lock") };
let fill_label = if t.pro { tl!("Fill:") } else { tl!("Fill") };
// Layer fill opacity (≠ Edit › Fill / path fill → Tô).
let lang = crate::i18n::current();
let fill_label = if t.pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
// In a narrow panel with long translated labels, drop the "Lock:" text (the icons keep
// their tooltips) rather than let the Fill label run over the lock icons.
let gap = ui.spacing().item_spacing.x;
@@ -2164,7 +2171,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut f = l.fill_opacity * 100.0;
let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
let scrub = scrub_pct_label(ui, fill_label, &mut f);
let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
if field.changed || scrub.changed {
actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag)));
}
@@ -3176,7 +3183,7 @@ fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "opacity": o / 100.0}));
}
let mut f = layer.fill_opacity * 100.0;
let r = widgets::slider_row(ui, tl!("Fill"), &mut f, 0.0..=100.0, "%", None);
let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "layers", "Fill"), &mut f, 0.0..=100.0, "%", None);
if r.drag_stopped() || (r.changed() && !r.dragged()) {
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "fill": f / 100.0}));
}
@@ -5229,8 +5236,12 @@ mod compact_visual_regressions {
}
fn toolbar_harness(kind: theme::ThemeKind, scale: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
toolbar_harness_sized(kind, scale, 360.0)
}
fn toolbar_harness_sized(kind: theme::ThemeKind, scale: f32, height: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 360.0)).with_pixels_per_point(scale).build_ui_state(
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, height)).with_pixels_per_point(scale).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
@@ -5299,6 +5310,18 @@ mod compact_visual_regressions {
}
}
/// A window taller than the tools keeps the colour chips right under the last tool, as
/// Photoshop does, instead of at the bottom of the toolbar.
#[test]
fn tall_toolbar_keeps_the_colour_chips_under_the_tools() {
for kind in theme::ThemeKind::ALL {
let harness = toolbar_harness_sized(kind, 1.0, 1600.0);
let last = harness.get_by_label("Zoom Tool (Z)").rect();
let swap = harness.get_by_label("Swap colours (X)").rect();
assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}");
}
}
#[test]
fn capture_compact_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
+28 -16
View File
@@ -261,7 +261,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2
return p;
}
let (out, lines) = match &snap.gesture {
Gesture::Point => live_crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)),
Gesture::Point => crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)),
Gesture::Guide => snap.targets.snap_point(p, tol),
Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => {
let d = [p[0] - snap.start[0], p[1] - snap.start[1]];
@@ -275,18 +275,26 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2
out
}
/// Snap the proposed handle on the image, calculated from the original camera. Snapping the
/// pointer in the moving camera feeds the previous frame's pan back into the resize and makes
/// Snap a crop handle being dragged: the handle itself (the frame's edge or corner), not the
/// pointer, which grabbed it up to the grab radius away (#1919: snapping the pointer left a strip
/// as wide as that offset between the frame and the edge it showed as snapped to). With Auto
/// Center Preview the handle is found on the image, calculated from the original camera: snapping
/// the pointer in the moving camera feeds the previous frame's pan back into the resize and makes
/// a stationary pointer oscillate near the canvas edges.
fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec<SnapLine>)> {
fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec<SnapLine>)> {
if app.ui.tool != Tool::Crop {
return None;
}
let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view: Some(view) } = app.crop.drag? else { return None };
let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view } = app.crop.drag? else { return None };
let deg = crate::crop_ui::angle(app);
let q = crate::crop_mode::to_press(app, Some(view), p);
let g = crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio: crate::crop_ui::resize_ratio(app, rect, mods), alt: mods.alt };
let (frame, _) = crate::crop_mode::resized_frame(g)?;
let ratio = crate::crop_ui::resize_ratio(app, rect, mods);
let q = view.map_or(p, |view| crate::crop_mode::to_press(app, Some(view), p));
// The frame for the pointer at `q`, as `crop_ui` resizes it.
let frame_at = |q: [f64; 2]| match view {
Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f),
None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)),
};
let frame = frame_at(q)?;
let at = |r: [f64; 4]| {
let c = crate::crop_ui::center(r);
crate::crop_ui::turn([c[0] + f64::from(hx) * (r[2] - r[0]) / 2.0, c[1] + f64::from(hy) * (r[3] - r[1]) / 2.0], c, deg)
@@ -304,35 +312,39 @@ fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, tar
if deg == 0.0 {
lines.retain(|line| if line.vertical { hx != 0 } else { hy != 0 });
}
// Auto Center Preview moves both screen edges. On the image the opposite edge stays put,
// so the grabbed edge travels twice as far as the pointer (once with Alt).
let scale = if mods.alt { 1.0 } else { 2.0 };
if lines.is_empty() {
return Some((p, lines));
}
// Auto Center Preview moves both screen edges. On the image the opposite edge stays put,
// so the grabbed edge travels twice as far as the pointer (once with Alt). Without it the
// handle follows the pointer.
let scale = if view.is_some() && !mods.alt { 2.0 } else { 1.0 };
// How much the frame's extent grows per unit of pointer travel: both sides with Auto Center
// Preview or Alt, else the grabbed one.
let grow = if view.is_some() || mods.alt { 2.0 } else { 1.0 };
let original = at(rect);
let travel = crate::crop_ui::turn([handle[0] - original[0], handle[1] - original[1]], [0.0, 0.0], -deg);
let mut local = [(travel[0] + d[0]) / scale, (travel[1] + d[1]) / scale];
if deg == 0.0
&& hx != 0
&& hy != 0
&& let Some(k) = g.ratio
&& let Some(k) = ratio
{
// A corner with a ratio has one degree of freedom. Put the other axis on the same
// ratio, rather than letting the raw pointer's unused axis override the snapped edge.
if lines.iter().any(|line| line.vertical) {
local[1] = f64::from(hy) * ((rect[2] - rect[0] + 2.0 * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / 2.0;
local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow;
} else {
local[0] = f64::from(hx) * ((rect[3] - rect[1] + 2.0 * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / 2.0;
local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow;
}
}
let d = crate::crop_ui::turn(local, [0.0, 0.0], deg);
let q = [start[0] + d[0], start[1] + d[1]];
if let Some((snapped_frame, _)) = crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { q, ..g }) {
if let Some(snapped_frame) = frame_at(q) {
let h = at(snapped_frame);
lines.retain(|line| (if line.vertical { h[0] } else { h[1] } - line.pos).abs() < 1e-3);
}
Some((crate::crop_mode::from_press(app, view, q), lines))
Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines))
}
/// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it).
+11 -2
View File
@@ -58,11 +58,16 @@ impl PreviewTextures {
if !s.is_finite() || s <= 0.0 {
return (0, false);
}
// Nearest Neighbor is point sampling: downsampled mip levels have already averaged
// neighbouring texels, so using one would blur a pixel-art transform preview.
if nearest_interpolation {
return (0, true);
}
if s < 0.5 {
// log2(1/s) levels down, at most 12 (4096× smaller).
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, nearest_interpolation);
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, false);
}
let crisp = nearest_interpolation || (zoom >= CRISP_ZOOM && scale <= 1.05);
let crisp = zoom >= CRISP_ZOOM && scale <= 1.05;
(0, crisp)
}
@@ -203,6 +208,10 @@ mod tests {
assert_eq!(PreviewTextures::choose(1.0, 1.0, true), (0, true));
assert_eq!(PreviewTextures::choose(0.25, 1.0, false), (2, false));
assert_eq!(PreviewTextures::choose(0.3, 1.0, false), (1, false));
// Nearest Neighbor must use the full-resolution texels, even when zoomed far out.
// A mipmap would average adjacent opaque/transparent pixels before sampling.
assert_eq!(PreviewTextures::choose(0.25, 1.0, true), (0, true));
assert_eq!(PreviewTextures::choose(1.0 / 4096.0, 1.0, true), (0, true));
assert_eq!(PreviewTextures::choose(f32::NAN, 1.0, false), (0, false));
assert_eq!(PreviewTextures::choose(1e-9, 1.0, false).0, 12);
}
+137 -16
View File
@@ -1251,9 +1251,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
}
// Distort only moves corners (and the whole box from inside): no rotating, no edges.
// Even in Distort mode, remember an outside press so a plain click can
// accept the transform. A drag outside remains inert in that mode, and in
// accept the transform. A drag outside remains inert in that mode, in Skew, and in
// Content-Aware Scale, which only scales and moves.
pv.gesture = (distort_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
pv.gesture = (mode_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
}
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
let g = pv.gesture;
@@ -1268,15 +1268,20 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
return true;
}
let legacy = app.session.prefs().general.use_legacy_free_transform;
let type_layer = moves_type(app, &t);
if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut())
&& distort_allows(t.mode, g.hit)
&& mode_allows(t.mode, g.hit)
&& (t.content_aware.is_none() || g.hit != Hit::Outside)
{
let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) };
let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods));
// Content-Aware Scale: ⌘ neither skews nor distorts.
mods.command &= t.content_aware.is_none();
apply_drag(s, g, [x, y], mods);
if t.content_aware.is_some() {
let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods));
// Content-Aware Scale: ⌘ neither skews nor distorts.
mods.command = false;
apply_drag(s, g, [x, y], mods);
} else {
apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, mods));
}
}
}
}
@@ -1318,11 +1323,11 @@ pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) ->
mods
}
/// Skew / Distort / Perspective modes: a handle drag acts as if that gesture's keys were held
/// (⌘-drag an edge skews, ⌘-drag a corner distorts, ⌘⌥⇧-drag a corner adds perspective).
/// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a
/// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]).
fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
match (mode, hit) {
(TransformMode::Skew, Hit::Edge(_)) | (TransformMode::Distort, Hit::Corner(_)) => mods.command = true,
(TransformMode::Distort, Hit::Corner(_)) => mods.command = true,
(TransformMode::Perspective, Hit::Corner(_)) => {
mods.command = true;
mods.alt = true;
@@ -1333,9 +1338,67 @@ fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::
mods
}
/// In Distort mode only corner drags (and moving the box from inside) do anything.
fn distort_allows(mode: TransformMode, h: Hit) -> bool {
mode != TransformMode::Distort || !matches!(h, Hit::Outside | Hit::Edge(_))
/// A handle drag in Edit › Transform `mode` (`Free` for Free Transform). `type_layer`: the box
/// moves editable type, which only takes affine transforms.
pub(crate) fn apply_mode_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mode: TransformMode, type_layer: bool, mut mods: egui::Modifiers) {
if !mode_allows(mode, g.hit) {
return;
}
match (mode, g.hit) {
// Skew, as in Photoshop (#3437): a corner moves alone along one of its two edges,
// whichever the drag mostly follows; ⌥ moves the opposite corner the other way.
(TransformMode::Skew, Hit::Corner(i)) if i < 4 => {
let (d, q0) = ([p[0] - g.start[0], p[1] - g.start[1]], g.quad0);
// Corners 0..3 = TL, TR, BR, BL: the neighbour along the top/bottom edge, and along
// the left/right one.
let along = |j: usize| {
let e = [q0[i][0] - q0[j][0], q0[i][1] - q0[j][1]];
let l = e[0].hypot(e[1]);
let u = if l > 0.0 { [e[0] / l, e[1] / l] } else { [0.0, 0.0] };
let t = d[0] * u[0] + d[1] * u[1];
[t * u[0], t * u[1]]
};
let (hn, vn) = ([1, 0, 3, 2][i], [3, 2, 1, 0][i]);
let (h, v) = (along(hn), along(vn));
let (m, partner) = if h[0].hypot(h[1]) >= v[0].hypot(v[1]) { (h, hn) } else { (v, vn) };
// Editable type stays a parallelogram: the corner's whole edge slides with it.
let moved: &[usize] = if type_layer { &[i, partner] } else { &[i] };
let mut q = q0;
for k in 0..4 {
let sign = if moved.contains(&k) {
1.0
} else if mods.alt && (if type_layer { true } else { k == (i + 2) % 4 }) {
// ⌥: the opposite corner (on type, the opposite edge) the other way.
-1.0
} else {
continue;
};
if let (Some(dst), Some(src)) = (q.get_mut(k), q0.get(k)) {
*dst = [src[0] + sign * m[0], src[1] + sign * m[1]];
}
}
s.quad = keep_convex(q0, q);
s.pivot = carry_pivot(q0, s.quad, g.pivot0);
}
// Skew: an edge slides only along itself (⌘⇧ in Free Transform).
(TransformMode::Skew, Hit::Edge(_)) => {
mods.command = true;
mods.shift = true;
apply_drag(s, g, p, mods);
}
_ => apply_drag(s, g, p, mode_mods(mode, g.hit, mods)),
}
}
/// What a drag can do in each Edit › Transform mode: in Distort only the corners (and moving the
/// box from inside); in Skew everything but rotating from outside the box (#3437). A click
/// outside still commits.
fn mode_allows(mode: TransformMode, h: Hit) -> bool {
match mode {
TransformMode::Distort => !matches!(h, Hit::Outside | Hit::Edge(_)),
TransformMode::Skew => h != Hit::Outside,
_ => true,
}
}
/// The turn at each corner of a quad (cross products of consecutive edges): all the same sign
@@ -1770,8 +1833,12 @@ pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64
}
fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> {
// Content-Aware Scale's box only scales: no rotate arrow.
if !(p[0].is_finite() && p[1].is_finite()) || t.warp.is_some() || t.content_aware.is_some() || t.mode == TransformMode::Distort {
// Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow.
if !(p[0].is_finite() && p[1].is_finite())
|| t.warp.is_some()
|| t.content_aware.is_some()
|| matches!(t.mode, TransformMode::Distort | TransformMode::Skew)
{
return None;
}
let rotating = pv.is_some_and(|pv| pv.gesture.is_some_and(|g| g.hit == Hit::Outside));
@@ -1812,7 +1879,7 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option<Cur
return Some(if w.mesh.as_ref().and_then(|m| surface_at(m, p)).is_some() { CursorIcon::Grab } else { CursorIcon::Default });
}
let h = hit(t, p, tol);
if !distort_allows(t.mode, h) || (t.content_aware.is_some() && h == Hit::Outside) {
if !mode_allows(t.mode, h) || (t.content_aware.is_some() && h == Hit::Outside) {
return Some(CursorIcon::Default);
}
if rotate_cursor_dir(app, p).is_some() {
@@ -2964,6 +3031,60 @@ mod tests {
apply_drag(s, g, to, mods);
}
/// A handle drag in Edit › Transform `mode`, as the canvas applies it (on a pixel layer).
fn drag_in(mode: TransformMode, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] {
drag_on(mode, false, from, to, mods)
}
fn drag_on(mode: TransformMode, type_layer: bool, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] {
let mut s = session();
let g = Gesture { hit: hit(&s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot };
apply_mode_drag(&mut s, g, to, mode, type_layer, mods);
s.quad
}
/// Skew on editable type, checked in Photoshop: a corner can't move alone (type stays a
/// parallelogram), so it slides its whole edge: the top or bottom edge for a mostly
/// horizontal drag, the left or right one for a mostly vertical drag. ⌥ moves the opposite
/// edge the other way. Side handles are as on any layer.
#[test]
fn skew_mode_on_type_slides_the_corners_edge() {
let (skew, none) = (TransformMode::Skew, egui::Modifiers::NONE);
assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], none), [[10.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
assert_eq!(drag_on(skew, true, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 41.0], [0.0, 50.0]]);
assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], egui::Modifiers::ALT), [[10.0, 0.0], [110.0, 0.0], [90.0, 50.0], [-10.0, 50.0]]);
assert_eq!(drag_on(skew, true, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
}
/// Edit › Transform › Skew, checked in Photoshop (#3437): a corner moves alone, horizontally or
/// vertically (whichever way the drag mostly goes); the top and bottom handles slide only
/// horizontally, the left and right ones only vertically; ⌥ moves the opposite corner or edge
/// the other way. Nothing scales.
#[test]
fn skew_mode_moves_a_corner_or_an_edge_along_one_axis() {
let skew = TransformMode::Skew;
let none = egui::Modifiers::NONE;
// Corner (top right), mostly horizontal, then mostly vertical: only that corner moves.
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], none), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
assert_eq!(drag_in(skew, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 50.0], [0.0, 50.0]]);
// Top edge: horizontal only, both its corners.
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
// Left edge: vertical only.
assert_eq!(drag_in(skew, [0.0, 25.0], [6.0, 33.0], none), [[0.0, 8.0], [100.0, 0.0], [100.0, 50.0], [0.0, 58.0]]);
// ⌥: the opposite corner, and the opposite edge, go the other way.
let alt = egui::Modifiers::ALT;
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], alt), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [-10.0, 50.0]]);
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], alt), [[12.0, 0.0], [112.0, 0.0], [88.0, 50.0], [-12.0, 50.0]]);
// ⇧ changes nothing in Skew mode.
let shift = egui::Modifiers::SHIFT;
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], shift), drag_in(skew, [100.0, 0.0], [110.0, 3.0], none));
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], shift), drag_in(skew, [50.0, 0.0], [62.0, 7.0], none));
// A drag outside the box doesn't rotate it in Skew mode.
assert_eq!(drag_in(skew, [130.0, 25.0], [120.0, 60.0], none), corners([0.0, 0.0, 100.0, 50.0]));
// Free Transform is unchanged: a corner scales.
assert_ne!(drag_in(TransformMode::Free, [100.0, 0.0], [110.0, 3.0], none)[2], [100.0, 50.0]);
}
fn close(a: [[f64; 2]; 4], b: [[f64; 2]; 4]) -> bool {
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-6)
}
+13 -3
View File
@@ -454,9 +454,19 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
ui.add_enabled_ui(on, |ui| match kind {
"align" => {
let mut a = v.as_str().unwrap_or("Left").to_string();
let opts = [tl!("Left"), tl!("Center"), tl!("Right"), "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyAll"]
.map(|o| (o.to_string(), o));
let opts: Vec<(String, &str)> = opts.to_vec();
// English values for the engine; labels go through `dropdown`'s `tl!`.
let opts: Vec<(String, &str)> = [
("Left", "Left"),
("Center", "Center"),
("Right", "Right"),
("JustifyLeft", "Justify last left"),
("JustifyCenter", "Justify last centered"),
("JustifyRight", "Justify last right"),
("JustifyAll", "Justify all"),
]
.into_iter()
.map(|(v, l)| (v.to_string(), l))
.collect();
if crate::widgets::dropdown(ui, &format!("style-align-{id}"), &mut a, &opts, 110.0) {
set = Some(json!({ "align": a }));
}
+5 -2
View File
@@ -1848,6 +1848,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
axis_sliders(app, ui, &c);
row(ui, &mut |ui| {
let k = shown_scale(app);
// Hover tips stay English until wired through `tl!` in a multi-locale change
// (`every_tl_literal_is_translated` requires every complete catalog).
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
app.ui.tool_options.type_size = v;
apply(app, ui.ctx(), json!({"size": v, "metricsAsShown": true}));
@@ -1904,18 +1906,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
});
// Faux styles: T (bold) T (italic) TT Tᴛ T̲ T̶, sharing the row evenly.
let caps = c.caps;
// Tip strings are English keys; `toggle_cell` runs them through `tl!`.
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
(
"TT",
tl!("All Caps"),
"All Caps",
caps == photocraft_doc::text::Caps::AllCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
),
(
"Tᴛ",
tl!("Small Caps"),
"Small Caps",
caps == photocraft_doc::text::Caps::SmallCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
),
+2 -1
View File
@@ -1193,7 +1193,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
let gesture = paint_gesture(ui, gesture_key);
let coalesce = |field: &str| format!("{}:{}:{gesture}", key(field), gesture_key.value());
// The shared collapsible section headers (#155).
if crate::props_layout::section(ui, "appearance", tl!("Appearance")) {
// Prefs "Appearance" is theme chrome (Giao diện); shape fill/stroke is Diện mạo.
if crate::props_layout::section(ui, "appearance", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Appearance")) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0));
if let Some(change) = swatch(ui, sh.fill.as_ref(), app.session.tools.foreground, tl!("Set shape fill type")) {
+11 -1
View File
@@ -459,6 +459,8 @@ fn wraps(id: &str) -> bool {
| "type.pasteLoremIpsum"
| "image.applyImage"
| "image.calculations"
| "select.saveSelection"
| "select.loadSelection"
)
}
@@ -1118,8 +1120,16 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
"add",
"subtract",
];
// The active layer's transparency, and its mask only when it has one.
let layer = st.active_layer.and_then(|l| d.layer(l));
let mut load = chans.clone();
load.extend(["transparency", "mask", "selection"].map(String::from));
if layer.is_some() {
load.push("transparency".into());
}
if layer.is_some_and(|l| l.mask.is_some()) {
load.push("mask".into());
}
load.push("selection".into());
match id {
"select.saveSelection" => dialog(
app,
+52
View File
@@ -291,3 +291,55 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() {
assert_eq!(direct["layerCount"], 1);
assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3"));
}
#[test]
fn save_selection_opens_its_dialog_and_saves_on_confirm() {
// #3713: Select › Save Selection… asks for the channel, name and operation first.
let (mut app, ctx) = app_with(1);
app.run("select.all", json!({})).unwrap();
let revision = app.session.active().unwrap().revision;
let dialog = menu(&mut app, &ctx, "select.saveSelection", json!({})).unwrap()["dialog"].as_u64().expect("Save Selection opens a dialog");
assert_eq!(app.session.active().unwrap().revision, revision, "saved before confirmation");
assert!(app.session.active().unwrap().doc.channels.is_empty());
let fields = &mut app.ui.dialog_mut(dialog).unwrap().fields;
assert_eq!(fields["__command"], "select.saveSelection");
assert_eq!(fields["channel"], "new");
assert_eq!(fields["__choices"]["operation"], json!(["new", "replace", "add", "subtract", "intersect"]));
fields.insert("name".into(), json!("Keep"));
crate::dialogs::confirm(&mut app, dialog).unwrap();
let names: Vec<_> = app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect();
assert_eq!(names, ["Keep"]);
// A second save lists the new channel as a destination.
let dialog = menu(&mut app, &ctx, "select.saveSelection", json!({})).unwrap()["dialog"].as_u64().unwrap();
assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["__choices"]["channel"], json!(["new", "Keep"]));
}
#[test]
fn load_selection_opens_its_dialog_and_loads_on_confirm() {
// #3713: Select › Load Selection… used to fail with "missing `channel`".
let (mut app, ctx) = app_with(1);
app.run("select.all", json!({})).unwrap();
app.run("select.saveSelection", json!({"name": "Keep"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
let dialog = menu(&mut app, &ctx, "select.loadSelection", json!({})).unwrap()["dialog"].as_u64().expect("Load Selection opens a dialog");
assert!(app.session.active().unwrap().doc.selection.is_none(), "loaded before confirmation");
let fields = &app.ui.dialog_mut(dialog).unwrap().fields;
assert_eq!(fields["__command"], "select.loadSelection");
assert_eq!(fields["channel"], "Keep");
assert_eq!(fields["invert"], false);
assert_eq!(fields["operation"], "new");
let channels = fields["__choices"]["channel"].as_array().unwrap();
assert!(channels.contains(&json!("Keep")) && channels.contains(&json!("transparency")));
// The layer has no mask, so its mask is not offered.
assert!(!channels.contains(&json!("mask")), "{channels:?}");
crate::dialogs::confirm(&mut app, dialog).unwrap();
assert!(app.session.active().unwrap().doc.selection.is_some());
}
#[test]
fn load_selection_without_alpha_channels_offers_the_composite() {
let (mut app, ctx) = app_with(1);
let dialog = menu(&mut app, &ctx, "select.loadSelection", json!({})).unwrap()["dialog"].as_u64().unwrap();
assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB");
crate::dialogs::confirm(&mut app, dialog).unwrap();
}
+35
View File
@@ -246,6 +246,27 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive
value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts::default()).0
}
/// A single-line label for a row that also holds fixed-width controls: laid out no wider than
/// `max_w`, cut with '…' when the text (often a longer translation) does not fit, with the full
/// string on hover. Fixed chrome must never let a label overrun its row (#3580), so an *essential*
/// label beside a fixed control should use this rather than a show/hide fit check; a *redundant*
/// label (one whose control already carries the meaning, e.g. an icon row's caption) may be dropped.
pub fn elided_label_sensed(ui: &mut Ui, text: &str, max_w: f32, sense: Sense) -> Response {
let t = Tokens::get(ui.ctx());
let font = egui::TextStyle::Body.resolve(ui.style());
let galley = crate::tab_strip::elided(ui, text, font, t.text_dim, max_w);
let elided = galley.elided;
let (rect, resp) = ui.allocate_exact_size(galley.size(), sense);
ui.painter().galley(rect.min, galley, t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text));
if elided { resp.on_hover_text(text) } else { resp }
}
/// [`elided_label_sensed`] with hover only (a plain, non-interactive label).
pub fn elided_label(ui: &mut Ui, text: &str, max_w: f32) -> Response {
elided_label_sensed(ui, text, max_w, Sense::hover())
}
/// Height of a [`value_field`]'s box.
pub const VALUE_FIELD_H: f32 = 24.0;
@@ -1594,6 +1615,20 @@ mod tests {
);
}
/// #3580: the shared eliding label never lays out wider than the room it is given, so a longer
/// translation shrinks with '…' instead of overrunning its row; a short label is untouched.
#[test]
fn elided_label_never_exceeds_its_room() {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(240.0, 60.0)).build_ui(|ui| {
let long = super::elided_label(ui, "Preenchimento:", 24.0); // pt-br Fill:, the longest catalog
assert!(long.rect.width() <= 25.0, "elided to the room, got {}", long.rect.width());
let short = super::elided_label(ui, "Fill", 200.0);
assert!(short.rect.width() < 60.0, "a short label is not padded: {}", short.rect.width());
});
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(2);
}
#[test]
fn spectrum_popup_style_matches_main_menu_and_preserves_studio() {
for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::StudioLight] {
+52 -7
View File
@@ -10,7 +10,7 @@
//! [`fit_window`] also fixes the cause at startup: a window that doesn't fit its monitor is
//! maximized, which puts it in the work area (Photoshop opens maximized on small displays).
use egui::{Rect, Vec2, pos2};
use egui::{Rect, Vec2, pos2, vec2};
/// Room kept free at the monitor's bottom edge for a taskbar or Dock egui can't see (the Windows
/// taskbar is 40–48 pt tall; a bottom Dock about as much).
@@ -22,9 +22,19 @@ const MIN_VISIBLE: f32 = 160.0;
/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen`
/// is true for a maximized or full-screen window (already inside the work area).
pub fn visible_part(content: Rect, monitor: Option<Vec2>, inner: Option<Rect>, fills_screen: bool) -> Rect {
let (Some(m), Some(inner)) = (monitor, inner) else { return content };
let finite = m.x.is_finite() && m.y.is_finite() && inner.min.is_finite() && inner.max.is_finite();
if fills_screen || !finite || m.x < 1.0 || m.y < 1.0 {
let Some(m) = monitor else { return content };
if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 {
return content;
}
// #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has
// no `inner_rect` there, only the monitor's size. A window bigger than the monitor less the
// taskbar still runs under it wherever it is: at best its content starts at the monitor's top,
// so at most that much of it can be seen.
let Some(inner) = inner else {
let room = vec2(m.x, m.y - TASKBAR);
return trusted(content, Rect::from_min_size(content.min, room));
};
if !inner.min.is_finite() || !inner.max.is_finite() {
return content;
}
// Only the monitor's size is known, not where it is on the desktop: assume monitors tile the
@@ -36,7 +46,12 @@ pub fn visible_part(content: Rect, monitor: Option<Vec2>, inner: Option<Rect>, f
// Desktop → window coordinates.
let shift = content.min - inner.min;
let local = usable.translate(shift);
let visible = content.intersect(local);
trusted(content, local)
}
/// `content` cut to `usable`, unless that leaves too little to be a sensible guess.
fn trusted(content: Rect, usable: Rect) -> Rect {
let visible = content.intersect(usable);
if !visible.is_positive() || visible.height() < MIN_VISIBLE.min(content.height()) || visible.width() < MIN_VISIBLE.min(content.width()) {
return content;
}
@@ -68,11 +83,15 @@ pub fn fit_window(ctx: &egui::Context) -> bool {
if ctx.data(|d| d.get_temp::<bool>(fitted_id())).is_some() {
return false;
}
let content = ctx.content_rect();
let (monitor, outer, maximized, fullscreen) = ctx.input(|i| {
let v = i.viewport();
(v.monitor_size, v.outer_rect, v.maximized, v.fullscreen)
});
let (Some(m), Some(outer)) = (monitor, outer) else { return false };
// On Wayland the window's position, and so its outer rect, is never known (#3737): its content
// size is the least the window takes up.
let outer = outer.unwrap_or(content);
let Some(m) = monitor else { return false };
// macOS keeps new windows inside the screen's visible frame itself.
if cfg!(target_os = "macos") {
return false;
@@ -117,7 +136,6 @@ mod tests {
let (content, inner) = window(0.0, 0.0, 1440.0, 900.0);
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area");
assert_eq!(visible_part(content, None, Some(inner), false), content);
assert_eq!(visible_part(content, Some(MONITOR), None, false), content);
assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content);
assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content);
assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content);
@@ -126,6 +144,20 @@ mod tests {
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content);
}
#[test]
fn an_unknown_window_position_still_keeps_clear_of_the_taskbar() {
// #3737, Wayland: only the monitor's size is known. A window taller than the monitor less
// the taskbar can be seen at most down to there, wherever it is.
let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0));
assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0));
assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
// One that fits, or a maximized one, is all visible.
let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0));
assert_eq!(visible_part(small, Some(MONITOR), None, false), small);
assert_eq!(visible_part(content, Some(MONITOR), None, true), content);
}
fn frame(ctx: &egui::Context, monitor: Option<Vec2>, outer: Option<Rect>, maximized: Option<bool>) -> Vec<egui::ViewportCommand> {
let mut input = egui::RawInput::default();
if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) {
@@ -158,6 +190,19 @@ mod tests {
);
let ctx = egui::Context::default();
assert!(!frame(&ctx, Some(MONITOR), big, Some(true)).contains(&egui::ViewportCommand::Maximized(true)));
// Wayland: no outer rect, but a content area bigger than the monitor is maximized too (#3737).
let ctx = egui::Context::default();
let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0))), ..Default::default() };
if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) {
v.monitor_size = Some(MONITOR);
v.maximized = Some(false);
}
let mut out = ctx.run_ui(input, |ui| {
fit_window(ui.ctx());
});
out.textures_delta.clear();
let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default();
assert_eq!(cmds.contains(&egui::ViewportCommand::Maximized(true)), !cfg!(target_os = "macos"));
assert!(fits(vec2(1366.0, 720.0), MONITOR) && !fits(vec2(1366.0, 740.0), MONITOR) && !fits(vec2(1440.0, 700.0), MONITOR));
}
}
@@ -24,6 +24,7 @@ fn harness() -> Option<(Harness, Writes)> {
file_dialog: Some(Box::new(|request, _parent, reply| {
reply.send(match request {
FileDialogRequest::Save { suggested } => Some(FileDialogAnswer::SaveTo(suggested)),
FileDialogRequest::Folder { .. } => None,
FileDialogRequest::Open { .. } => None,
})
})),
+8 -5
View File
@@ -25,11 +25,14 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| Polski | `pl` | one / few (2–4, not 12–14) / many |
| Ελληνικά | `el` | one / other |
| Nederlands | `nl` | one / other |
| Tiếng Việt | `vi` | one form |
Every non-English catalog covers the current menu labels, `tl!` literals, blend mode names,
and generated preference labels. Tests enforce that coverage. This does not include every
engine error or status message: those remain English, as do automation command IDs and
user-provided document, layer and preset names.
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is
selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization
keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under
review. This does not include every engine error or status message: those remain English, as do
automation command IDs and user-provided document, layer and preset names.
## Switch without restarting
@@ -41,7 +44,7 @@ documents, undo history and tools remain available throughout a language change.
The existing preference store saves the selection for future launches. `auto` follows the
native system locale; an unsupported code follows the same fallback. Regional tags such as
`fr-CA`, `ko-KR` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
`fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
Brazilian Portuguese catalog.
+1 -1
View File
@@ -124,7 +124,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report |
| FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import |
| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_lp decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist |
| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 10 MCP tasks |
| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 20 MCP tasks |
## Tools
+2 -1
View File
@@ -26,7 +26,8 @@ The toolbar's foreground and background colour chips follow the `Tokens::round_c
## Rules
- **Colours and radii come from tokens.** Read them with `Tokens::get(ctx)`; never hard-code a colour in a widget.
- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`.
- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`, `elided_label`.
- **A label never overflows its row.** Wrap by default. Beside a fixed-width control, or anywhere the row cannot reflow, use `widgets::elided_label(ui, text, max_w)`: a longer translation shrinks with `…` and shows the full string on hover instead of running over its neighbours (#3580). A *redundant* caption may be dropped on a narrow dock where its control already carries the meaning (e.g. the Layers lock icons); an *essential* one elides. This mirrors Photoshop: a fixed layout with elision and tooltips, not per-locale reflow.
- **Icons:** Lucide SVGs in `assets/icons` (ISC licence), embedded via `icon_data.rs`. Regenerate that file when you add icons.
- **Fonts:** Inter (UI) and JetBrains Mono (numbers), both OFL. Named families `medium` and `semibold` are available via `theme::medium()` and `theme::semibold()`.
- **Photoshop layout grammar (Pro):**
@@ -9,7 +9,7 @@ Icon=ai.storyteller.photocraft
Terminal=false
# The native runner does not complete desktop startup-notification handshakes.
StartupNotify=false
StartupWMClass=photocraft
StartupWMClass=ai.storyteller.photocraft
Categories=Graphics;2DGraphics;RasterGraphics;Photography;
Keywords=photo;image;editor;psd;layers;retouch;paint;
MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;
+1
View File
@@ -7,5 +7,6 @@
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [PhotocraftVersion] Setup Wizard" />
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [PhotocraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [PhotocraftVersion]" />
<String Id="RequiresWindows10" Value="PhotoCraft needs Windows 10 or later. This version of Windows can't run it." />
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [PhotocraftVersion] Setup Wizard" />
</WixLocalization>
+12
View File
@@ -31,6 +31,18 @@
DowngradeErrorMessage="A newer version of PhotoCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<!-- Windows 10 or later. The Rust standard library has needed it since 1.78: on Windows 7 and
8 the app fails at launch with "The procedure entry point ProcessPrng could not be located
in bcryptprimitives.dll" (#2310), so the installer says so instead of installing it.
`CurrentMajorVersionNumber` exists only from Windows 10 on, in the 32- and 64-bit
registry views alike; MSI's own VersionNT has reported both 603 and 1000 there.
`Installed` keeps repair and uninstall working on any Windows. -->
<Property Id="WINDOWS10ORLATER" Secure="yes">
<RegistrySearch Id="WindowsMajorVersion" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion"
Name="CurrentMajorVersionNumber" Type="raw" />
</Property>
<Launch Condition="Installed OR WINDOWS10ORLATER" Message="!(loc.RequiresWindows10)" />
<!-- MSI caches this icon using its Id as the filename. Advertised shortcuts to an
.exe need an .exe icon identifier (ICE50), even when the source is an ICO. -->
<Icon Id="PhotocraftIcon.exe" SourceFile="$(var.IconPath)" />
+1 -1
View File
@@ -192,4 +192,4 @@ id = "FILE-216-6"
target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands"
status = "partial"
issue = 216
note = "automation/tests/agent_tasks.rs: 10 MCP tasks"
note = "automation/tests/agent_tasks.rs: 20 MCP tasks"
+1
View File
@@ -579,6 +579,7 @@ mod tests {
("el", "el.tsv"),
("uk", "uk.tsv"),
("nl", "nl.tsv"),
("vi", "vi.tsv"),
] {
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
let text = fs::read_to_string(&catalog_path).unwrap_or_default();