Compare commits

..
689 Commits
Author SHA1 Message Date
BFlat 2438d38084 Merge pull request #8514 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:22 +07:00
bflatastic d2e349ee6c README: the app icon above the title 2026-10-22 17:58:16 +07:00
aiongg 78b46b1a99 Merge pull request #3283 from abc2cb/codex/github-smart-filters-usability-20261011
Smart Filters: distinct editors, shared disclosure and stable alpha
2026-10-22 00:26:00 -04:00
aiongg 3458eee861 Merge main into #3283
dialogs.rs: main's Displace-map prompt runs before the fallible confirm_command. filter_dialog.rs: main's ask_displace_map uses the new confirm_command signature; SMART_FILTER_SET_PARAMS stays for main's uses; a smart-filter edit without a "layer" param previews the active layer, as the command runs it (main's smart_displace_preview_uses_the_map_document).
2026-10-22 02:16:49 +00:00
Zuujis 38bc5e916c refactor(atn): build the clipping-mask and three adjustment events with macros (#8401)
Create/Release Clipping Mask and Brightness/Contrast, Black & White and
Gradient Map were written out by hand with the same body as
adjustment_event! or each other, which SonarCloud counts as duplicated new
code (photocraft's quality gate fails on 9.8% duplication from the GrpL
entry). adjustment_event! takes an optional command id, and clipping_event!
builds the two clipping events. The mapping table is unchanged.
2026-10-21 20:28:12 -04:00
Dylan M. Taylor f4bc90b93f ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#8368)
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
2026-10-21 20:12:22 -04:00
Dylan M. Taylor 3ec52a9ff1 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#8369)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 20:11:03 -04:00
Dylan M. Taylor 9b18f57344 test: use tokio::time::sleep in async test poll loop (SonarCloud rust:S7488) (#8373)
The multi-thread #[tokio::test] polled with a blocking std::thread::sleep,
stalling a runtime worker. Use tokio::time::sleep(..).await instead.
2026-10-21 19:35:38 -04:00
aiongg ad694eb17d Merge pull request #8370 from storytold/sonar-fix/actions-script-injection
security: prevent script injection in fuzz workflow (SonarCloud S7630)
2026-10-21 19:26:09 -04:00
aiongg ba40dffb25 Merge pull request #8355 from aiongg/integration-10
Integration batch: #8229, David's follow-ups and 85 FredApps-approved PRs on main
2026-10-21 19:02:54 -04:00
aiongg ce85a5ae78 Merge PR #8229: Integration batch: 8 PRs on main 2026-10-21 22:30:00 +00:00
aiongg 33214b70f4 Merge PR #6708: Paths: the path selected in the Paths panel is drawn on the canvas (#6550) 2026-10-21 22:30:00 +00:00
aiongg 989a685b3f Merge PR #6208: Preferences: Save in Background off saves in the foreground (#6207) 2026-10-21 22:29:59 +00:00
aiongg 3b04ff873c Merge PR #5840: Export As: Resample menu, incl. Nearest Neighbor (#5838) 2026-10-21 22:29:59 +00:00
abc2cbandClaude Opus 5.5 08fa0ed4fb Smart Filters: keep main's thumbnail scaling tests test-only
The merge of main kept `#[cfg(test)]` on `mod editor_tests;` only, so
`layer_thumbnail_scaling_tests` (which uses the dev-only egui_kittest)
was compiled into normal builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:13:10 +03:00
abc2cbandClaude Opus 5.5 a335b58e21 Merge upstream/main 43b14883 into the Smart Filters branch
#3289, #3974 and #3975 landed in main, so the Smart Filters row code meets
#3974's thumbnail geometry.

Conflicts:
- smart_ui.rs: the rows keep our layout (separate name and Blending
  Options targets, shared disclosure, real mask thumbnail) and take
  #3974's geometry: the header height and the shared-mask side come from
  smart_filters_header_geometry, sub-rows are SUB_ROW_H, the mask is
  hidden at None and the header name follows it. #3974's own
  drag_started and selection handling is dropped, ours already covers
  both through the name target. #3974's scaling test module is kept
  next to editor_tests.
- All 20 i18n catalogs: both sides appended rows at the end; kept both,
  exact duplicates dropped, dupscan reports 0 problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:29 +03:00
aiongg f32f882f3a Merge main into integration-10
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	crates/engine/src/file_cmds/tests.rs
#	crates/engine/src/float_cmds.rs
#	crates/engine/src/float_cmds/tests.rs
#	xtask/src/corpus.rs
#	xtask/src/corpus_pins.rs
#	xtask/src/main.rs
2026-10-21 21:56:26 +00:00
aiongg 21a952698a Integration: Lens Flare and the render filters' colours read with main's double and typed enum_name helpers, for #6160 2026-10-21 21:56:22 +00:00
aiongg 1a388f8ba7 Merge PR #8229: Integration batch: 8 PRs on main
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
2026-10-21 21:56:22 +00:00
aiongg 64d042aef5 Integration: Create Clipping Mask also maps the GrpL code and a named-layer target, for #6881 and #4496 2026-10-21 21:56:21 +00:00
aiongg 0e9cbbc88e Integration: one tools.setColors and one clipping-mask mapping in .atn export, for #6881 and #4496 2026-10-21 21:56:21 +00:00
aiongg 719fb44d42 Merge PR #6881: Actions: .atn Select Subject, Select and Mask, Color Range, clipping and layer masks, colours map to commands (part of #6745) 2026-10-21 21:56:21 +00:00
aiongg 3b16c2bc98 Merge PR #5729: Move tool: ΔX/ΔY readout beside the pointer while dragging (#5727) 2026-10-21 21:56:21 +00:00
aiongg bd133eee81 Merge PR #5855: Displace: OK asks for the displacement map, as in Photoshop (#5806) 2026-10-21 21:56:21 +00:00
aiongg 43e9587590 Merge PR #6363: New Document: Color Profile, the working space or another profile of the mode (#321) 2026-10-21 21:56:21 +00:00
aiongg 20adb5f1d7 Merge PR #6410: Artboards: layers outside the boards stay visible over the pasteboard (#5712) 2026-10-21 21:56:21 +00:00
aiongg 1bc45862cb Merge PR #6813: A layer that doesn't fit in GPU memory no longer breaks GPU drawing: older pages are freed and the layer is retried 2026-10-21 21:56:21 +00:00
aiongg 64526a6c28 Merge PR #7904: Filter Gallery › Halftone Pattern: a tone-keeping screen at 45° 2026-10-21 21:56:21 +00:00
aiongg f97c28828d Merge PR #6873: HEIF: honour the nclx colour description; HLG/PQ HEICs open as linear BT.2020 32-bit (#6752) 2026-10-21 21:56:21 +00:00
aiongg 1837a19362 Merge PR #6908: Background redraw: keep the last finished composites, so a solo's restore uploads instead of recompositing (part of #6528) 2026-10-21 21:56:21 +00:00
aiongg 29388de427 Merge PR #6919: Selections don't mark a document modified (#6865) 2026-10-21 21:56:21 +00:00
aiongg c71322043c Merge PR #7941: Light themes outline the slider knob (UI-217-11, #7927) 2026-10-21 21:56:21 +00:00
aiongg e4a413241a Merge PR #8004: Windows on ARM: auto picks DirectX 12 for Qualcomm graphics (#7537) 2026-10-21 21:56:21 +00:00
aiongg 403dc5043a Merge PR #8096: CodeRabbit follow-ups for the #8095 batch 2026-10-21 21:56:21 +00:00
aiongg 07e4020360 Merge PR #8098: PSD layer comps: an entry without enab inherits the previous visibility, as in Photoshop (#8093) 2026-10-21 21:56:21 +00:00
aiongg 3b6fabb04a Merge PR #6801: Actions that start with Deselect play on when nothing is selected 2026-10-21 21:56:21 +00:00
aiongg a06c4639f6 Merge PR #5590: Warp: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) 2026-10-21 21:56:21 +00:00
aiongg 7b81efddcb Merge PR #5436: IO: open .psd through the PSD reader, not codecs (#5258) 2026-10-21 21:56:21 +00:00
aiongg dd83f87d3c Merge PR #5581: Layers: Group Layers creates the group collapsed (#5571) 2026-10-21 21:56:20 +00:00
aiongg 0fe1d3233d Merge PR #5907: Text: apply anti-alias mode shaping when rasterizing type (#5902) 2026-10-21 21:56:20 +00:00
aiongg c865e3b93b Merge PR #5928: BMP export: Indexed Color documents save as palette BMPs 2026-10-21 21:56:20 +00:00
aiongg b9b97f15ff Merge PR #6243: GPU: pin the keep-overlap ordering with a tile-growth regression test 2026-10-21 21:56:20 +00:00
aiongg 5eada4996a Merge PR #7219: Actions: record File › Export › Export As and replay it with its settings (#7207) 2026-10-21 21:56:20 +00:00
aiongg abfb8cce96 Merge PR #8042: Codecs: Fix corrupted CMYK JPEG exports (#8008) 2026-10-21 21:56:11 +00:00
aiongg fa5a06de4f Merge PR #8053: Pencil: align 1 px click snapping with the line walker (#4895) 2026-10-21 21:56:11 +00:00
aiongg fb9da6d581 Merge PR #7303: Keyboard Shortcuts: Shift is recorded when capturing a shortcut 2026-10-21 21:56:11 +00:00
aiongg a870371600 Merge PR #7270: Brushes: folders with search matches can be collapsed while searching 2026-10-21 21:56:11 +00:00
aiongg f38e0e40bd Merge PR #7304: Freeform Pen: ⇧ constrains freehand strokes to straight 45° lines 2026-10-21 21:56:11 +00:00
aiongg 9ce8ab897c Merge PR #7317: Color panel: RGB slider mode (#7172) 2026-10-21 21:56:11 +00:00
aiongg a0d6bf2dcd Merge PR #7791: Camera raw: a developed raw keeps its EXIF (RW2 included), so File Info shows its Camera Data (#6957) 2026-10-21 21:56:11 +00:00
aiongg b75f0cf21d Merge PR #7799: Displace on a Smart Object: the map is embedded in the smart filter, so it displaces (#7423) 2026-10-21 21:56:10 +00:00
aiongg f3c20b9242 Merge PR #7626: Flatpak: a second launch hands its files to the running PhotoCraft over a Unix socket (#1540) 2026-10-21 21:56:10 +00:00
aiongg b0833a893c Merge PR #7694: perf(format): reuse .pcraft writers between document saves 2026-10-21 21:56:10 +00:00
aiongg 5189019b5b Merge PR #7696: fix(actions): preserve effective Stroke width in ATN export 2026-10-21 21:56:10 +00:00
aiongg c2d5a3fd64 Merge PR #7708: fix(actions): refuse lossy fractional Ripple ATN export 2026-10-21 21:56:10 +00:00
aiongg 7b4dfb7ff6 Merge PR #7980: perf(raster): tile content bounds compare rows in blocks (P32, #211) 2026-10-21 21:56:10 +00:00
aiongg 6695bd5a8b Merge PR #8005: Open: a file without type never waits for the font scan (#7332) 2026-10-21 21:56:10 +00:00
aiongg f8bea646d4 Merge PR #8009: automation: edit.installedProfiles is no host-path command 2026-10-21 21:56:10 +00:00
aiongg 1d278f3061 Merge PR #8012: fix(liquify): keep frozen pixels stable near mask boundaries (#7989) 2026-10-21 21:56:10 +00:00
aiongg a194e0ebb2 Merge PR #7772: Vanishing Point: perspective clone samples without allocating per pixel (#7491) 2026-10-21 21:56:10 +00:00
aiongg 7ab402c025 Merge PR #6028: Zoom tool menu: Zoom In and Zoom Out last, as in Photoshop (#6005)
# Conflicts:
#	docs/context-menu-parity.md
2026-10-21 21:56:02 +00:00
aiongg 4a04cb6d14 Merge PR #6146: refactor: share hsb conversions between photocraft-color and the color picker 2026-10-21 21:55:49 +00:00
aiongg 3a7180cf8c Merge PR #6509: Type: commit the edit when another layer or document becomes active (#6483) 2026-10-21 21:55:49 +00:00
aiongg 8bb5f31e73 Merge PR #6830: Tab in Full Screen Mode shows the menu bar and panels; leaving Full Screen restores them as before (#6760) 2026-10-21 21:55:49 +00:00
aiongg 419f6ef4c7 Merge PR #6910: Web: one press per tap, so menus open again on touch (#2095) 2026-10-21 21:55:49 +00:00
aiongg 5ef5c71164 Merge PR #6912: Shortcuts: custom bindings on PageUp/PageDown (and other multi-word keys) fire again (#6827) 2026-10-21 21:55:49 +00:00
aiongg e09bf5728f Merge PR #7784: Actions: a background filter's step is recorded only by the recording it started in (#7289) 2026-10-21 21:55:49 +00:00
aiongg 97a1a67f38 Merge PR #7959: Actions: Emboss saves to and loads from .atn as Photoshop's Embs step (#7946) 2026-10-21 21:55:49 +00:00
aiongg f0137c6ace Merge PR #7963: Fix File Info showing 1/1 s for exposures just under one second 2026-10-21 21:55:49 +00:00
aiongg 1bf99d9866 Merge PR #7966: Slice Select gets its own icon: the Slice knife with a pointer (#7859) 2026-10-21 21:55:49 +00:00
aiongg db33aa6ad4 Merge PR #7951: Note tool: a selected note shows its author and colour in the options bar (#7940) 2026-10-21 21:55:49 +00:00
aiongg e7964eb78e Merge PR #7647: Carry on an open path from its end with the Pen (#6593) 2026-10-21 21:55:49 +00:00
aiongg e135613187 Merge PR #7653: Move typed text with a drag from just outside it (#5956) 2026-10-21 21:55:49 +00:00
aiongg 4c46f0c0fc Merge PR #7928: Fixed Size marquee: Width and Height in any length unit (#7910) 2026-10-21 21:55:49 +00:00
aiongg 149fdb19b6 Merge PR #4762: fix(screen-picker): cancel queued requests without stale replies 2026-10-21 21:55:49 +00:00
aiongg e730ebf07b Merge PR #7385: Content-Aware Fill workspace: an erased sampling area previews as an error, and a failed OK keeps it open (#7384) 2026-10-21 21:55:49 +00:00
aiongg 38f684e4e4 Merge PR #7661: Move tool: a selection lifts up to a pixel's own coverage (#7655) 2026-10-21 21:55:49 +00:00
aiongg d71ff0d34d Merge PR #7580: Preserve saved PCraft previews when manifest replacement fails 2026-10-21 21:55:49 +00:00
aiongg 5c8adfe81d Merge PR #7595: Toolbar: colour chips stay centred when the tools scroll (#7316) 2026-10-21 21:55:49 +00:00
aiongg 574235c98a Merge PR #7640: Open the selection menu on a Quick Selection right-click (#7268) 2026-10-21 21:55:48 +00:00
aiongg 43b1488325 Merge pull request #3975 from abc2cb/codex/fill-mask-preview-20261012
Fill layers: match masks in creation and Pattern Fill preview (part 3/3)
2026-10-21 17:53:41 -04:00
aiongg 34a1089140 Merge pull request #3974 from abc2cb/codex/layers-subrow-scaling-20261012
Layers Panel: Smart Filters mask follows thumbnail size, sub-rows stay compact (part 2/3)
2026-10-21 17:53:29 -04:00
aiongg 04d66caccd Merge pull request #3289 from abc2cb/feat/layers-panel-photoshop-options
Layers Panel: flyout, options and thumbnail sizes (part 1/3)
2026-10-21 17:53:17 -04:00
abc2cbandClaude Opus 5.5 e07d293d5b Mask thumbnail test: expect the key to include the document height
17f149e7 keys mask thumbnails on the document height as well as the
width; main's #2117 test still computed the old width-only key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:29:46 +03:00
abc2cbandClaude Opus 5.5 17f149e73c Smart Filters: one command builder for OK, mask thumbnails keyed by size
- smart_ui.rs: confirm_command sends every Smart Filter edit through
  smart_filter_dialog::edit_command, the builder the canvas preview
  already uses, so preview and OK cannot build different payloads.
- lib.rs: the mask thumbnail cache revision includes the document height
  as well as the width. Two documents that restore the same layer id and
  width no longer share a thumbnail framed for the other's height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:14:11 +03:00
abc2cbandClaude Opus 5.5 6da0673cd1 Merge upstream/main 6d8cbac7 into the Smart Filters branch
Conflicts:
- filter_dialog.rs: preview_document_for_target keeps our proxy scaling
  and `p` clone; main's Bitmap rule (the preview drops Output resolution,
  #5559) is applied to the same `p`.
- hu/sr/tr/vi catalogs: both sides appended rows at the end; kept both, no
  duplicate keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:14:11 +03:00
abc2cbandClaude Opus 5.5 ff5708361d Merge the Layers Panel flyout branch (upstream/main 6d8cbac7 merged) into the Fill layers branch
One conflict, pattern_cmds.rs: insert_fill_layer keeps the default_mask
argument and takes main's placement(command, params)? signature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:12:09 +03:00
abc2cbandClaude Opus 5.5 ce3fff235e Merge the Layers Panel flyout branch (upstream/main 6d8cbac7 merged) into the Smart Filters mask branch
No conflicts: the flyout branch carries the main merge and its review fixes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:11:00 +03:00
abc2cbandClaude Opus 5.5 743a38e64d Layers panel reorder: Content-Aware Move preview wins, moveTo drops asserted
- canvas.rs: the Content-Aware Move preview now comes before the
  Layers-panel reorder preview. The reorder preview is built from the
  session document and would drop a pending Content-Aware Move result.
- layer_multi_cmds.rs: preview_rejects_invalid_drop_unchanged also runs
  layer.moveTo for each rejected drop: the missing target, `into` on a
  non-group and the invalid position are errors, the self-drop is a
  no-op, and the document stays unchanged each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:09:58 +03:00
Dylan M. Taylor 5933793982 security: prevent script injection in fuzz workflow (SonarCloud S7630)
The Fuzz step interpolated the user-controlled workflow_dispatch input
${{ inputs.fuzz_seconds }} directly into a run: shell command, allowing
script injection by whoever triggers the workflow. Pass it via an env var
(FUZZ_SECONDS) and reference "$FUZZ_SECONDS" so the value is treated as
data, not shell. Resolves SonarCloud githubactions:S7630 (BLOCKER).
2026-10-21 17:08:18 -04:00
abc2cbandClaude Opus 5.5 8cc64b86a1 Merge upstream/main 6d8cbac7 into the Layers Panel flyout branch
Conflicts:
- commands.rs: layer.duplicate keeps the "Add copy" option; main's #5880
  dropped the second damage pass (`before` and note_insert) for single
  duplicates, so the clone of the document goes away too.
- jobs.rs: the success arm runs main's relayout damage first, then our
  Expand New Effects disclosure update.
- panels.rs: the Background no longer loads a transparency selection on a
  thumbnail click (main), and a thumbnail size of None has no thumbnail to
  click (ours); main's `pos` / `on_thumb` now come before layer.select, so
  the size guard moved there.
- hu/sr/tr/vi catalogs: both sides appended rows at the end; kept both, no
  duplicate keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 23:57:52 +03:00
David Mann ef361309c8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 22:49:29 +02:00
David Mann 15943f5126 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 22:49:00 +02:00
David Mann 46fe7f6a69 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 22:48:29 +02:00
David Mann 925cb86c4b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	xtask/src/corpus.rs
#	xtask/src/corpus_pins.rs
#	xtask/src/main.rs
2026-10-21 22:40:54 +02:00
skylar taylor-barrick 6d8cbac7dc Merge pull request #2945 from diegochagas/feat/xcf-read
Open GIMP XCF files: layers, groups, masks, blend modes, channels and selection
2026-10-21 14:35:32 -06:00
skylar taylor-barrick 18e2d45420 Merge pull request #6318 from robertoffmoura/fix/floating-selection-move-lag
Fix lag when dragging a floating selection with the Move tool
2026-10-21 14:25:55 -06:00
Dylan M. Taylor 87fa191818 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:47 -04:00
Diego Chagas ca18689304 Open GIMP XCF files: layers, groups, masks, modes, channels and selection (#1076)
PhotoCraft reads GIMP's .xcf (and .xcf.gz) files, read only, through a new
reader in photocraft-io written from the public XCF specification: versions
0 to 26, uncompressed, RLE and zlib tiles, 8/16/32-bit integer and
16/32/64-bit float precisions (linear precisions get a linear profile),
pixel layers at any position, groups by item path with pass-through,
layer masks applied or disabled, opacity, visibility, locks, colour tags,
the 2.10 and legacy blend modes mapped to Photoshop's (modes without an
equivalent open as Normal with a note), the ICC profile, resolution,
guides, the comment, extra channels and the saved selection; indexed
images open as RGB. Text, vector and link layers open as their pixels;
layer effects, paths and floating selections are reported.

Fixtures are written by GIMP 3.2 itself (scripts/xcf_fixtures.py), each
with GIMP's merged rendering as a PNG. In gamma-encoded blend and
composite spaces our composite matches GIMP within 0.5/255; GIMP's
linear-light default, its Clip to backdrop and its clamp-after-alpha
for unbounded modes are documented and measured in docs/xcf.md.
2026-10-21 16:57:22 -03:00
Dylan M. Taylor 8e518d8169 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:04 -04:00
David MannandClaude Opus 5.5 a1b56b2d72 Address CodeRabbit review: a layered Duotone writes its real merged image, for #4935
A Duotone PSD opens flattened from its merged image (its layer records are
not read back), but with Maximize Compatibility off a layered Duotone was
saved with the white placeholder, so reopening it showed white. Its merged
image is now always written, like a document without layer records.

Test: a_layered_duotone_keeps_its_image_without_maximize_compatibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:04:36 +02:00
David MannandClaude Opus 5.5 45c7b57b6a Address CodeRabbit review: render smart filters with unmodeled settings stay verbatim, for #6160
Clouds, Difference Clouds, Fibers and Lens Flare opened as editable smart
filters even when their descriptor held an extra or repeated setting, a
value of another type (a long centre, a unit seed), an RGBC colour with
extra keys or out of 0-255, or a Lens Flare enum of another type. A
re-save rebuilt the item without it. They now need exactly the keys and
value types Photoshop writes; anything else stays a psd.unsupportedFilter
and round-trips byte for byte, as main's newer filters do with only_keys.

Test: render_filters_with_settings_we_dont_model_stay_verbatim (30 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:04:36 +02:00
aiongg d5b6da319e Merge PR #7924: Smart Blur: 3.5× faster at radius 12 with identical output (#7921) 2026-10-21 18:15:12 +00:00
aiongg f3dbc0fcfb Merge PR #7839: Copy: text selected in an error notice is copied, not the layer (#7807) 2026-10-21 18:15:12 +00:00
aiongg 66e54580b7 Merge PR #7838: Free Transform: a result may take what Memory Usage allows, not 2^30 px (#7807) 2026-10-21 18:15:11 +00:00
aiongg 8aca796773 Merge PR #7821: feat(ui): search font input field sticky on scroll (#7792) 2026-10-21 18:15:11 +00:00
aiongg d5bf6f0398 Merge PR #7846: Scale layer-effect noise by coverage so it follows the glow 2026-10-21 18:15:10 +00:00
aiongg 0c965366d5 Merge PR #7902: Slice Select: the selected slice's handles resize it (#7863) 2026-10-21 18:15:10 +00:00
aiongg 9e55e81677 Merge PR #7898: Lock Slices: Slice Select can't select a locked slice, and both slice tools say why (#7865) 2026-10-21 18:15:09 +00:00
aiongg 02eeecd59f Merge PR #7870: Shortcuts: Ctrl+F4 closes the document beside Ctrl+W (Windows/Linux) 2026-10-21 18:15:08 +00:00
aiongg f6ed54e83e Merge PR #7389: fix(atn): a preset-only Photo Filter step exports its resolved colour (#7235) 2026-10-21 18:15:08 +00:00
aiongg 54229918c9 Merge PR #7271: macOS: keep the bundle's Dock icon after the window opens 2026-10-21 18:15:07 +00:00
aiongg be2760a7bc Merge PR #7353: test(engine): make the define-brush mask allocation fail on its own (fail-after-one) (#212) 2026-10-21 18:15:07 +00:00
aiongg f471a08224 Merge PR #7397: Auto-Blend Layers: turn the Background into a layer before masking it 2026-10-21 18:15:05 +00:00
aiongg 0001f4d0a3 Merge PR #7572: Menu bar: Alt held through a shortcut no longer takes the keyboard, so Enter reaches the dialog (#7481) 2026-10-21 18:15:04 +00:00
aiongg 8e84c55296 Merge PR #7576: Presets: an Export As preset survives a damaged sibling set (test for #6906) 2026-10-21 18:15:04 +00:00
aiongg 8e66e5fcdd Merge PR #7587: Actions: stop when a Save step's document is closed by a Save Document script (#7367) 2026-10-21 18:15:03 +00:00
aiongg 418c70acb2 Merge PR #7617: Studio: the floating Properties card has the Adjustments tab (#7265) 2026-10-21 18:15:03 +00:00
aiongg a6702058db Merge PR #7627: Actions: Minimum / Maximum with Preserve: Roundness is no longer exported as Squareness (#7233) 2026-10-21 18:15:02 +00:00
aiongg 94d28535d3 Merge PR #6464: Survive a lost window GPU device: draw nothing, save unsaved work for recovery, close (#3438, #1215) 2026-10-21 18:15:02 +00:00
aiongg ef62950bff Merge PR #7837: Free Transform: a result over the pixel limit says so, not "not enough memory" (#7807) 2026-10-21 18:15:01 +00:00
aiongg 8baf2ef75b Merge PR #7751: Actions: a Selective Color step keeps the values of its scalar form 2026-10-21 18:15:00 +00:00
aiongg c5630b2a5d Merge PR #7636: Show Technique and Range in the glow Layer Style pages (#6698) 2026-10-21 18:15:00 +00:00
aiongg f90f4b164a Merge PR #7769: Lasso: a short straight segment places a vertex instead of closing the outline 2026-10-21 18:14:59 +00:00
aiongg da9de5c07f Merge PR #2324: feat(toolbar): select grouped tools using press-drag-release 2026-10-21 18:14:59 +00:00
aiongg ebd173de80 Merge PR #6907: test(ui): a toolbar click with no prior hover picks the tool (part of #2603) 2026-10-21 18:14:58 +00:00
aiongg 548eeafcee Merge PR #6629: Canvas overlays stay visible while panning (#6592) 2026-10-21 18:14:57 +00:00
aiongg 0c18dbff9a Merge PR #6472: fix(ui): preserve shape stroke settings at zero width 2026-10-21 18:14:57 +00:00
David MannandClaude Opus 5.5 30c420761d Merge remote-tracking branch 'origin/main' into integration-batch-5
Conflicts with main's smart-filter and layer-style work, both sides kept:
- smart_map: main's 27 more filters and this batch's Clouds, Difference
  Clouds, Fibers and Lens Flare (45 known); the item colours are written for
  Pointillize, Tiles and the three coloured render filters.
- layer_style: main's new Stroke default (1 px inside black) with this
  batch's Overprint.
- psd_export: main's verbatim unknown adjustments with this batch's unpadded
  Gradient Map check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:50:16 +02:00
aiongg 6a663c107b Merge PR #6397: PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 16:57:01 +00:00
aiongg 1d6ba33d76 Merge PR #6317: PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:56:50 +00:00
aiongg b4c64e2fd5 Merge PR #6305: PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:56:49 +00:00
aiongg c32ff12f96 Merge PR #6299: PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:56:49 +00:00
aiongg 17ad0d74b7 Merge PR #6297: PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:55:56 +00:00
aiongg 4570ad3333 Merge PR #6213: Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212) 2026-10-21 16:55:43 +00:00
aiongg dccd9f23e1 Merge PR #6211: Zoom tool: hold to zoom continuously with Animated Zoom on (#6210) 2026-10-21 16:55:42 +00:00
aiongg f1ce0770aa Merge PR #6206: Performance: a cancelled Gaussian Blur job stops within budget (#6205) 2026-10-21 16:55:42 +00:00
aiongg c653757ad5 Merge PR #5847: Shapes: Density and Feather on shape layers (#5846) 2026-10-21 16:55:41 +00:00
aiongg a0acbec162 Merge PR #5109: Tests: generated large-file fixtures and a P20 number (#5107) 2026-10-21 16:55:41 +00:00
aiongg 479eeeae19 Merge PR #3516: scorecard: five rows catch up with work already on main 2026-10-21 16:55:40 +00:00
Higgs32584andClaude Opus 5.5 27466a8e58 CI: rerun the Windows integration job (panic_hunt timing flake, unrelated to this PR)
The previous head ba7e4e9e passed every check; the branch sync to cc479f60 changed
nothing this PR touches, and the failing engine test timed out at its 4 s deadline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:41:59 -04:00
Roberto Moura f4ea3139d7 Keep selection snap targets when dragging a guide with the Move tool 2026-10-21 16:37:54 +00:00
Korg1ch a6b950c4fc test(actions): Export As is recorded and replayed; a bad recorded step is an error (#7207) 2026-10-21 19:29:16 +03:00
Korg1ch d93458b72b fix(actions): record File › Export › Export As and replay it with its destination and settings (#7207) 2026-10-21 19:29:16 +03:00
David Mann f4d54906eb Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 18:20:56 +02:00
David MannandClaude Opus 5.5 e74f4758d8 Merge origin/main into feat/file-215-2-mezzotint-halftone
Mezzotint and Color Halftone use main's only_keys (now also rejecting a
repeated setting) and enum_from (the Mezzotint table becomes string
pairs) instead of the branch's own only_keys and typed_enum; the new
malformed cases use main's pixelate_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:19:55 +02:00
David MannandClaude Opus 5.5 e57452d945 Merge origin/main into feat/file-215-2-other
Offset and Custom use main's only_keys (now also rejecting a repeated
setting, for every filter that uses it) and enum_from instead of the
branch's own only_keys and typed_enum.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:15:33 +02:00
David Mann 27581e4d87 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/gaps.md
2026-10-21 18:07:44 +02:00
David Mann 7021fa7a7c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-21 18:06:39 +02:00
David Mann 61fb0efaef Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 18:04:10 +02:00
David Mann 8cc639d9b6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 18:02:50 +02:00
David Mann c27f1670d0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 18:00:21 +02:00
David Mann 38ca7288a5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 17:58:45 +02:00
David MannandClaude Opus 5.5 68d8130b77 Use main's enum_from and unit_in for the blur smart filters
The blur tables are now (Photoshop value, ours) string pairs, so the typed
enum check goes through main's shared enum_from instead of a second reader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:55:47 +02:00
aiongg 773ccdf25a Merge PR #7771: Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) 2026-10-21 15:55:27 +00:00
aiongg 879ad9d149 Merge PR #7733: Painting symmetry: canvas edits move the axis with the pixels (#7732) 2026-10-21 15:55:26 +00:00
aiongg 65f7150ccd Merge PR #6681: Content-Aware Move: live preview for large selections (TOOL-213-4, #6680) 2026-10-21 15:55:26 +00:00
aiongg 26df98e878 Merge PR #5889: Image: Bitmap dialog with Output resolution and per-method settings (#5559) 2026-10-21 15:55:25 +00:00
David Mann 8c97f411ba Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 17:54:25 +02:00
David Mann 4d837a9269 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 17:53:36 +02:00
David Mann 6848c92e28 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:52:03 +02:00
David Mann f784980d34 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:50:34 +02:00
Rene Leonhardt aa715535f3 perf(format): reuse save cache ownership safely
Move cached keys and compressed buffers between saves, and compare weak
allocation pointers without temporary strong-reference updates. Keep
payload budgets and archive bytes unchanged.

Allow fallible tile edits after a cached save. Move pixel storage when
only weak references remain; use a fallible copy for shared storage and
restore the original tile if the copy fails.

Cover cache key reuse, edits after saves at every depth, and weak/shared
tile ownership. Name the fixed revisions behind the existing timings.
2026-10-21 17:49:09 +02:00
David Mann 4cce737673 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:48:32 +02:00
David Mann 23d564165f Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:48:11 +02:00
aiongg a7825b9e0f Integration: regenerate docs/scorecard.md for the batch 2026-10-21 15:47:34 +00:00
aiongg 556931ad90 Merge PR #6397: PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 15:47:12 +00:00
aiongg 29c073b75c Integration: Mezzotint and Color Halftone use main's only_keys and enum_name, and the new test cases use pixelate_item; the PR's helper copy dropped, for #6317 2026-10-21 15:47:04 +00:00
aiongg a936a93464 Merge PR #6317: PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 15:46:55 +00:00
aiongg 0034c989e6 Integration: Offset and Custom use main's only_keys, which also rejects a repeated key, and enum_from; the PR's only_keys and typed_enum dropped, for #6305 2026-10-21 15:46:46 +00:00
aiongg 9bf386adc9 Merge PR #6305: PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 15:46:25 +00:00
aiongg 44a9161960 Integration: Surface Blur's radius read with unit_in, unit_value dropped, for #6299 2026-10-21 15:46:14 +00:00
aiongg 8564c74861 Merge PR #6299: PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298) 2026-10-21 15:46:07 +00:00
aiongg a8a6894ca7 Merge PR #6297: PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 15:46:07 +00:00
aiongg e7ddf6d7c7 Merge PR #7771: Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) 2026-10-21 15:45:55 +00:00
aiongg 5baa87b131 Merge PR #7762: Perspective Crop: Color Sampler points move with the image (#7760) 2026-10-21 15:45:54 +00:00
aiongg 3be9e7bcb1 Merge PR #7733: Painting symmetry: canvas edits move the axis with the pixels (#7732) 2026-10-21 15:45:54 +00:00
aiongg e07df7877f Merge PR #6708: Paths: the path selected in the Paths panel is drawn on the canvas (#6550) 2026-10-21 15:45:53 +00:00
aiongg a425efcb2a Merge PR #6681: Content-Aware Move: live preview for large selections (TOOL-213-4, #6680)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:53 +00:00
aiongg 57aef126b0 Merge PR #6213: Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:52 +00:00
aiongg aa1a1ea5d4 Merge PR #6211: Zoom tool: hold to zoom continuously with Animated Zoom on (#6210)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:51 +00:00
aiongg 19b96d5351 Merge PR #6208: Preferences: Save in Background off saves in the foreground (#6207)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:51 +00:00
aiongg 066aae7233 Merge PR #6206: Performance: a cancelled Gaussian Blur job stops within budget (#6205)
# Conflicts:
#	docs/gaps.md
2026-10-21 15:45:46 +00:00
aiongg f9587384c7 Merge PR #5889: Image: Bitmap dialog with Output resolution and per-method settings (#5559) 2026-10-21 15:45:39 +00:00
aiongg e027e048fc Merge PR #5847: Shapes: Density and Feather on shape layers (#5846)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:38 +00:00
aiongg 51d26f36bc Merge PR #5840: Export As: Resample menu, incl. Nearest Neighbor (#5838)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:38 +00:00
aiongg 7ad35873b8 Merge PR #5109: Tests: generated large-file fixtures and a P20 number (#5107)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:37 +00:00
aiongg 86eb4e6739 Merge PR #3516: scorecard: five rows catch up with work already on main
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-21 15:45:33 +00:00
aiongg cbc091dd68 Integration: .act colour tables in the parity doc's presets table, for #5407 2026-10-21 15:45:17 +00:00
aiongg 77557d75f1 Merge PR #8229: Integration batch: 8 PRs on main
# Conflicts:
#	crates/engine/src/layer_style.rs
#	crates/io/src/psd_export.rs
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
2026-10-21 15:45:07 +00:00
Michael Higgins 616ab9d56a Merge branch 'storytold:main' into fix/6745-atn-event-mapping 2026-10-21 11:37:25 -04:00
Korg1ch 3d3b67b74c test(ui-egui): light themes outline the slider knob at 3:1 (#7927) 2026-10-21 18:28:10 +03:00
Korg1ch 76e4e81e33 fix(ui-egui): outline the slider knob in the light themes (#7927) 2026-10-21 18:28:10 +03:00
Korg1ch 66ff3bf7eb test(engine): a Displace smart filter keeps its embedded map; an outside new map is refused before it is read (#7423) 2026-10-21 18:26:00 +03:00
Korg1ch d13facdc28 test(ui): slice handles resize their edges; the middle still moves; locked slices keep their size (#7863) 2026-10-21 18:25:43 +03:00
Korg1ch dcfac95286 test(ui): Slice Select and Slice refuse presses while slices are locked (#7865) 2026-10-21 18:25:36 +03:00
Korg1ch 06d55f7adb fix(ui): locked slices can't be selected with Slice Select, and both slice tools say why (#7865) 2026-10-21 18:25:35 +03:00
Korg1ch 3c7ee9eded fix(engine): Displace on a Smart Object embeds its map in the smart filter, as Photoshop does (#7423) 2026-10-21 18:23:45 +03:00
Korg1ch 019eb273cc fix(ui): Slice Select's handles resize the selected slice (#7863) 2026-10-21 18:23:27 +03:00
Michael Higgins 7e7defd0fb Merge branch 'storytold:main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-21 11:23:13 -04:00
Branch Sync 960d927a37 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:25 -04:00
Branch Sync d08a16507c Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:17 -04:00
Branch Sync 24970b1622 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:07 -04:00
Branch Sync f5161d9cb1 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:49 -04:00
Branch Sync 1069d30cb1 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:31 -04:00
Branch Sync 0750751261 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:17 -04:00
Branch Sync d5d937ac3e Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:09 -04:00
Branch Sync be9cca9bd9 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:16:55 -04:00
Branch Sync a81d472f5a Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:16:39 -04:00
Branch Sync cc479f604a Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:16:15 -04:00
Branch Sync f343abda5d Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:15:43 -04:00
Branch Sync 1d3e94aa4c Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:15:37 -04:00
Branch Sync 643c9b7044 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:15:30 -04:00
Michael Higgins da307f8f17 Merge branch 'storytold:main' into fix/auto-blend-background-mask 2026-10-21 11:09:47 -04:00
Roberto Moura 8352b26c1a Fix lag when dragging a floating selection with the Move tool
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
  with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
  sharing interior Arc<Tile> allocations in O(1) and activating the
  solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
  content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
  opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
  identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
  prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
  move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
  tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
  canvas so navigator_gpu is not evicted into a synchronous full-document
  CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
  add a direct read_rgba_into fast path for unmasked Normal layers over
  an empty backdrop.
2026-10-21 13:21:10 +00:00
4decb8dd30 Address CodeRabbit review: .act vs .aco by content, strict corpus walks (#5407, #4496)
- swatches.import with bytes only (no path or format): an .act whose first
  colour is 0, 1, x or 0, 2, x still went to the .aco reader, which ignores
  trailing bytes and returned a handful of junk swatches. At the two .act
  lengths (768/772) an .aco header now counts only when its sections span
  the whole file (new `aco::sections_len`); a Photoshop .aco of exactly
  768 bytes is still an .aco. Test: act_that_starts_like_an_aco_is_still_an_act.
- The .atn corpus tests (psd and engine) skipped unreadable folders and
  entries silently; they now fail with the path.
- act::write doc: the trailer is written when `count` or `transparent` is given.

Co-authored-by: Abdul karim mia <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 14:06:56 +02:00
David MannandClaude Opus 5.5 4bc06b906b Address CodeRabbit review: bound the soft shape frame scan
Look in from each edge through strips that double in thickness, so a subtracted
or intersected subpath's empty margin costs a few renders instead of one per row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:15:11 +02:00
David MannandClaude Opus 5.5 23e9b9ccf8 Address CodeRabbit review: a Zoom hold belongs to its own press and canvas
- A hold left from an earlier press (the tool changed before its release)
  is only reused by the press that started it (same origin and start time);
  a new press starts a fresh one instead of zooming at once about the old
  anchor or never becoming a hold.
- Only a press the canvas owns becomes a hold: not one on a dialog, a
  scrollbar or the rotate compass over the canvas.
- Each canvas (tiled windows, secondary windows) keeps its own hold, so a
  hold on one never zooms another.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:10:59 +02:00
David MannandClaude Opus 5.5 87862a6f05 Address CodeRabbit review: a repeated Offset/Custom setting stays verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:03:44 +02:00
David MannandClaude Opus 5.5 6686b22d91 Address CodeRabbit review: a repeated Mezzotint/Color Halftone setting stays verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:00:18 +02:00
David MannandClaude Opus 5.5 645e47e46d Address CodeRabbit review: slice.list lists each stored slice once, with its pieces
slice.list and slice.set report a stored slice with its own canvas-clipped
rectangle (what slice.set takes back) and its visible parts under "pieces";
a slice wholly covered by later ones is still listed, with number null.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:52:35 +02:00
David Mann 0efeabf7ad Merge remote-tracking branch 'origin/main' into fix/overlapping-slices-split 2026-10-21 12:45:50 +02:00
David MannandClaude Opus 5.5 47a7f8021b Address CodeRabbit review: a rejected Perspective Crop leaves the samplers and their removed count alone (test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:45:09 +02:00
David MannandClaude Opus 5.5 cd407f1a05 Address CodeRabbit review: refuse a Bitmap Output size past 300,000 px
Keeping the print size at a higher Output resolution could ask for more than
the 300,000 px document limit; the side was clamped, silently changing the
print size. Refuse it instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:42:22 +02:00
David MannandClaude Opus 5.5 4010044b7b Merge origin/main into feat/ui-217-4-animated-zoom
docs/scorecard.md: take main's and regenerate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:42:17 +02:00
David MannandClaude Opus 5.5 87e5d77b0e Address CodeRabbit review: save the brush owner only with its brush
toolBrushes.owner names the tool whose brush lastBrush holds. While the
session brush can't be remembered yet (a sampled tip too big to embed),
lastBrush keeps the previous owner's brush, so keep that owner saved too;
otherwise the next launch gave the old owner's brush to the tool selected
since. Exit flushes the brush before the tool memory, as a frame does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:40:09 +02:00
David Mann dd99f53662 Merge remote-tracking branch 'origin/main' into fix/perspective-crop-samplers 2026-10-21 12:39:41 +02:00
David MannandClaude Opus 5.5 551241d408 Address CodeRabbit review: a turned dual symmetry splits the difference under a non-uniform Image Size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:39:06 +02:00
David Mann 7201189695 Merge remote-tracking branch 'origin/main' into fix/symmetry-follows-canvas 2026-10-21 12:31:29 +02:00
David MannandClaude Opus 5.5 60cd4ad099 Address CodeRabbit review: scope the Gaussian Blur speedup claim
The uncancelled speedup is for layers with locked transparency only, the
numbers are local runs and the perf baseline is not re-measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:26:07 +02:00
David Mann d073b42290 Merge remote-tracking branch 'origin/main' into perf/p25-blur-cancel 2026-10-21 12:24:58 +02:00
David MannandClaude Opus 5.5 a67b6ed5ca Address CodeRabbit review: narrow two coverage claims, cite evidence as file:line
BUG-207-5: only eight retouch tools are tested to follow the Channels panel target (Healing,
Spot Healing, Sponge and Remove are skipped by changes_gray). BUG-208-4: the Polygonal Lasso test
keeps the selection for Shift or Alt and commits a Shift polygon; no test commits an Alt one.
The notes this branch changes now cite file:line, as docs/development.md asks; ui-parity.md gets
a dated row for the marquee Shift+Alt test instead of rewriting the 2026-10-11 one.
Regenerated docs/scorecard.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:24:49 +02:00
David MannandClaude Opus 5.5 d3a5c28904 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:23:19 +02:00
David MannandClaude Opus 5.5 52640de2b5 Address CodeRabbit review: state each large-file test's memory need
large_file_bench needs about 30 GB (its PSB, as its doc comment says), not 16 GB; the opt-in
tests need about 12 GB (2 GB PSD) and 25 GB (4.4 GB PSB), as their #[ignore] reasons say.
Regenerated docs/scorecard.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:16 +02:00
David MannandClaude Opus 5.5 c69e6efe07 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:19:59 +02:00
David MannandClaude Opus 5.5 66abf411ad Merge origin/main into feat/ui-217-1-tool-options-persist
docs/scorecard.md: take main's and regenerate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:17:31 +02:00
David MannandClaude Opus 5.5 64534add6d Address CodeRabbit review: keep a faint sparse selection in the coarse preview
The coarse Content-Aware Move preview scaled the selection down in its own
8-bit format, so scattered 1/255 pixels averaged to 0 and the preview failed
with "the selection is empty" while the command moves them. Scale the
selection in 32-bit float instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:15:27 +02:00
David MannandClaude Opus 5.5 375cf82c10 Merge origin/main into feat/tool-213-4-cam-preview-large
docs/scorecard.md: take main's and regenerate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:15:27 +02:00
David Mann d98e03903d Merge remote-tracking branch 'origin/main' into feat/file-215-18-export-resample
# Conflicts:
#	docs/scorecard.md
2026-10-21 12:15:24 +02:00
David MannandClaude Opus 5.5 e5f4f8e260 Address CodeRabbit review: soft shape frame from the hard render, feather claim
A soft shape's transform frame, snap rectangle and Properties X/Y/W/H came from
the path plus the full stroke width on every side, so a centred or inside stroke
grew the frame when Density or Feather was set. The frame is now the pixels the
same shape renders at full Density without Feather (edge strips only, clipped to
the canvas), identical to the hard shape's for every stroke alignment.

feather_matches_photoshops_ramp now enforces the 1-level claim the scorecard
makes (measured max deviation 1 at 8, 16 and 32 bit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:14:46 +02:00
David MannandClaude Opus 5.5 901e19d30b Address CodeRabbit review: budget the Bitmap conversion's working buffers
A higher Output resolution passed the resample budget (document bytes only)
but then allocated ~40 B/px of f32 composite, gray and output buffers at the
new size. Budget the whole working set before resampling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:07:41 +02:00
David MannandClaude Opus 5.5 91a28160ff Address CodeRabbit review: blur smart filters check enum types, units and save ranges
Opening: Smart Blur and Radial Blur enums must have their measured enum
type, Surface Blur's radius must be #Pxl and Smart Blur's radius and
threshold plain doubles; anything else stays verbatim instead of being
re-written in another shape.
Saving: a present value that is not a number or lies outside the range
the filter opens with (Surface Blur radius 0.5, a text Radial Blur
centre) is an error with a warning, not a filter that reopens as
unsupported or a centre silently taken as 0.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:07:18 +02:00
David MannandClaude Opus 5.5 a2b5e4de4f Address CodeRabbit review: an oversized Perspective Warp stays verbatim; parity doc date and PSD row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:07:12 +02:00
David Mann e9b6710b89 Merge remote-tracking branch 'origin/main' into fix/6268-psd-perspective-warp-sf 2026-10-21 11:57:56 +02:00
David MannandClaude Opus 5.5 c2dbd974eb Move the descriptor helpers next to int() so the sibling smart-filter PRs share them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:56:46 +02:00
David MannandClaude Opus 5.5 3c77d1d6aa Address CodeRabbit review: Mezzotint/Color Halftone with an extra setting or a mistyped enum stay verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:55:43 +02:00
David Mann afa8b234ce Merge remote-tracking branch 'origin/main' into feat/file-215-2-blur 2026-10-21 11:55:05 +02:00
David MannandClaude Opus 5.5 19f9f50042 Address CodeRabbit review: keep a filter whose Fltr is not a descriptor verbatim
A recognized no-options filterID (or Shadows/Highlights) with a present
but unreadable Fltr opened as editable and lost the Fltr on save; it now
stays an unsupported filter, written back unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:54:26 +02:00
David MannandClaude Opus 5.5 bac554b181 Merge origin/main into fix/6550-selected-path-outline
Resolve vector_ui.rs: keep main's shows_anchors and the PR's
selected_saved_path; the selected saved path now shows its anchors with the
same tools as the work path (Path Selection and the Pen tools).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:49:56 +02:00
David Mann 5abe294af7 Merge remote-tracking branch 'origin/main' into fix/5559-bitmap-dialog 2026-10-21 11:40:10 +02:00
David Mann 2ed222416d Merge remote-tracking branch 'origin/main' into feat/file-215-2-mezzotint-halftone 2026-10-21 11:38:50 +02:00
David MannandClaude Opus 5.5 9164d2eebc Address CodeRabbit review: Offset/Custom with an extra setting or a mistyped enum stay verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:37:31 +02:00
David Mann b68b64ccb5 Merge PR #6160: feat(psd): editable Render smart filters (Clouds, Difference Clouds, Fibers, Lens Flare)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 11:37:14 +02:00
David Mann 86811ddeee Merge PR #6002: Stroke: keep Overprint (overprint)
# Conflicts:
#	crates/engine/src/layer_style.rs
#	crates/ui-egui/src/layer_style.rs
#	docs/file-format-parity.md
2026-10-21 11:36:58 +02:00
David Mann 5bf2898f4e Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/scorecard.md
2026-10-21 11:36:04 +02:00
David Mann d276fce259 Merge PR #8081: fix(psd): pad Gradient Map blocks (#8070) 2026-10-21 11:30:21 +02:00
David Mann a492716367 Merge PR #5990: Bevel & Emboss: keep the Gloss Contour's Anti-aliased (antialiasGloss) 2026-10-21 11:29:05 +02:00
David Mann 213eaa260d Merge PR #5407: Support .act color tables end to end (swatches + Color Table dialog) 2026-10-21 11:28:43 +02:00
David Mann b16910427c Merge PR #4935: PSD Duotone: read and write the ink block (names, colours, curves) 2026-10-21 11:28:32 +02:00
David Mann 27b8d133d8 Merge PR #4771: Fix mixed-order layered TIFF type geometry and unsafe text fallback 2026-10-21 11:28:23 +02:00
David Mann 4cfb576ee0 Merge PR #4496: .atn: a pinned corpus of real action sets, with byte-exact and load/save/reload tests (#219) 2026-10-21 11:28:19 +02:00
David Mann e7ff19f283 Merge remote-tracking branch 'origin/main' into feat/ui-217-4-save-in-background
# Conflicts:
#	docs/scorecard.md
2026-10-21 11:25:56 +02:00
David Mann 89dbbc9aca Merge remote-tracking branch 'origin/main' into feat/file-215-2-noparam 2026-10-21 11:25:55 +02:00
David Mann e7d82f219b Merge remote-tracking branch 'origin/main' into feat/file-215-2-other 2026-10-21 11:25:40 +02:00
f5543e85fc Integration: merge main, resolve conflicts (#5407)
main added the GPU adapter strings (#1885) at the end of every
translation table, where this PR adds the Color Table strings. Both
blocks are kept.

Co-authored-by: abdul-karim-mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:23:09 +02:00
Korg1ch 3d7d62e349 test(io): cmls entries without enab inherit the last visibility or start visible (#8093) 2026-10-21 05:05:56 +03:00
Korg1ch 385d2a7a01 fix(io): a layer comp entry without enab inherits the previous visibility, as Photoshop reads cmls (#8093) 2026-10-21 05:05:55 +03:00
Higgs32584andClaude Opus 5.5 8fa1217bcc Actions: Add Layer Mask maps only the target layer's mask
A `make` of a `Chnl` whose `At` reference has further items (the mask of
a named layer) was mapped to a layer.layerMask command, which acts on the
target layer. Such a step now stays a kept Photoshop step, reported as
unsupported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:33:02 -04:00
Higgs32584andClaude Opus 5.5 df7c4350eb Artboard pasteboard: cut around the layers as they are shown
The canvas draws `display_doc` (a Move drag's moved layers, a transform or
fill preview, ...), but the pasteboard holes for loose layers came from
the committed document, so a loose layer dragged over the pasteboard was
covered at its new position until the move was committed. Cut the holes
from the displayed document instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:32:59 -04:00
Higgs32584andClaude Opus 5.5 213b95e17c Merge upstream main into fix/6528-solo-restore-cache
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:22:35 -04:00
Higgs32584andClaude Opus 5.5 ae51ce8d82 Merge upstream main into fix/7332-open-no-font-wait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:57 -04:00
Higgs32584andClaude Opus 5.5 ec13017dfb Merge upstream main into fix/7367-atn-close-save-target
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:53 -04:00
Higgs32584andClaude Opus 5.5 f5a0587945 Merge upstream main into fix/auto-blend-background-mask
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:48 -04:00
Higgs32584andClaude Opus 5.5 8177fe2d45 Merge upstream main into fix/6865-selection-not-dirty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:32 -04:00
Higgs32584andClaude Opus 5.5 576b7cdfcb Merge upstream main into fix/2095-android-menus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:22 -04:00
Higgs32584andClaude Opus 5.5 3bcd357738 Merge upstream main into fix/2603-toolbar-click-without-hover
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:17 -04:00
Higgs32584andClaude Opus 5.5 f44bf80b86 Merge upstream main into fix/6745-atn-event-mapping
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:12 -04:00
Higgs32584andClaude Opus 5.5 b0acb119d6 Merge upstream main into fix/6752-heic-hlg
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:06 -04:00
Higgs32584andClaude Opus 5.5 ec51ae55da Merge upstream main into fix/6744-deselect-noop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:54 -04:00
Higgs32584andClaude Opus 5.5 d7d562287b Merge upstream main into fix/5712-artboard-pasteboard-hides-loose-layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:42 -04:00
Higgs32584andClaude Opus 5.5 967fd444ae Merge upstream main into fix/321-new-document-color-profile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:19 -04:00
Higgs32584andClaude Opus 5.5 7a712f96df Merge upstream main into fix/5806-displace-asks-for-map
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:00 -04:00
Higgs32584andClaude Opus 5.5 ba7e4e9e6f Merge upstream main into fix/5727-move-tool-offset-readout
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:17:50 -04:00
Higgs32584andClaude Opus 5.5 ac1a7443a9 GPU compositor: pass the timer and batch runs in the OOM retry (main changed submit_batch and run_cell)
main added a GPU timer argument to submit_batch and the batch runs to run_cell,
so the OOM submit-and-retry path no longer compiled after the merge. Also add
the admission-evicted bytes to the flush counter with saturating arithmetic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:09:52 -04:00
Higgs32584andClaude Opus 5.5 9c280d4c58 Background redraw: rustfmt the kept-composite cap check
cargo fmt --check failed on the if/else added in bbcc8321; rustfmt
(max_width 160, use_small_heuristics Max) wants it on one line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:09:42 -04:00
Higgs32584 57948f1155 Merge remote-tracking branch 'upstream/main' into fix/6563-layer-texture-oom 2026-10-20 21:09:26 -04:00
Higgs32584andClaude Opus 5.5 66c33840e0 Selections keep a document with a video layer clean
selection_only_change used same_pixels, which turns down every video layer,
so any selection step marked such a document modified. It now compares video
frames (painted and original) by tile pointer as well; same_pixels itself is
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:58:05 -04:00
Higgs32584andClaude Opus 5.5 c00045d7d9 Merge upstream/main into fix/7537-windows-arm-dx12
Resolve conflicts with #4's "Auto on Windows leaves Vulkan out when an
Intel adapter is present" (3c9c93ed) and #7813's adapter choice
(`configure` gained `wanted`):

- gpu_startup.rs: `intel_dx12_applied(.., vulkan_left_out)` from main and
  the patch's `dx12_default_applied` become one
  `dx12_default_applied(adapters, chosen, os, vulkan_left_out)` that
  returns the maker's name (Intel or Qualcomm). `vulkan_left_out` counts
  only for Intel, since only an Intel DXGI adapter makes
  `windows_auto_backends` drop Vulkan. `configure` keeps main's `wanted`
  parameter and Vulkan-free instance; the System Info note names the maker.
- Tests: keep both main's Intel instance test and the Qualcomm test,
  updated to the merged signature, plus one assertion that Vulkan being
  left out doesn't make a Qualcomm DX12 pick "the default".
- Module docs and docs/development.md: main's wording, with Qualcomm
  (Windows on ARM) added to the DX12 default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:55:06 -04:00
Higgs32584andClaude Opus 5.5 bbcc832103 Background redraw: keep finished composites within Memory Usage as it is now
A continued redraw reused the cap from when it began, and the finished composite
was stored with it; after Memory Usage dropped (or the document grew) it could be
kept over the current allowance. The cap is taken again when a redraw continues
and when it finishes (once, not per frame).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:54:05 -04:00
Higgs32584andClaude Opus 5.5 1133cb5a40 HEIF: tag an HDR image on unsupported primaries linear sRGB, with a warning
A PQ or HLG image is decoded to linear light and its ICC profile dropped.
When its nclx primaries have no built-in profile (e.g. DCI-P3, code 11), the
document was left untagged, so the linear samples were read as sRGB-encoded.
It is now tagged linear sRGB, which keeps the tones right, and the import
warns that the primaries were assumed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:53:51 -04:00
Higgs32584andClaude Opus 5.5 6dbc8956ad Document presets: check a rejected blank profile leaves preferences unchanged
Move the empty/whitespace/non-string Color Profile cases into the bad-input
table, which asserts the preferences (presets included) and their revision
are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:53:16 -04:00
Higgs32584andClaude Opus 5.5 98b4574545 Automation: deny file.new profiles that resolve the configured working space
`default` and the `working-*` aliases make file.new resolve Color Settings'
working profile, which on the desktop can be a user-configured .icc path.
Treat them like edit.assignProfile does; exact `working` and `none` skip
resolution and stay allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:53:16 -04:00
Higgs32584andClaude Opus 5.5 1b84acd2e3 GPU compositor: on a layer page OOM, submit and wait before the last retry
Pages dropped to make room can still be held by the work recorded since the
last submit, so the immediate retry may fail although the memory is about to
be freed. render now submits, waits for the GPU and runs the cell once more
before sending the refresh to the CPU (encode, which can't submit, still goes
to the CPU). Bytes dropped to admit pages also count towards the
intermediate-submit threshold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:52:51 -04:00
Higgs32584andClaude Opus 5.5 cd6e288820 Displace: the map picker's answer displaces the document that asked
If another document was activated while the picker was open, the filter ran on
that one instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:51:42 -04:00
Higgs32584andClaude Opus 5.5 d0d5e31019 Test that Deselect with nothing selected is still rejected outside playback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:50:59 -04:00
Higgs32584andClaude Opus 5.5 18b176c41e Move tool: the release commits the offset the readout and preview cap at
At the lowest zoom of a huge document a drag can exceed the ±10,000,000 px the
ΔX/ΔY readout and the live preview show; layer.translate got the raw distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:50:39 -04:00
David MannandClaude Opus 5.5 168701779c CodeRabbit follow-ups for the #8095 batch (#5807, #4816, #7180)
- type.setStyle/edit/create: an unknown `baseline` is a BadParams error and no longer resets
  superscript or subscript to Normal (#5807).
- Indexed/Bitmap PSD save warns whenever flattening loses something, also for a single hidden,
  masked, non-Normal, effect, type, shape or smart object layer, not only for two or more layers
  (#4816).
- brush.presets.importAbr: the apply step keeps only `replace` and `select`, not a second copy of
  a base64 `data` string of up to ~1.3 GiB (#7180).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 02:48:24 +02:00
404a17e711 Integration: write the .atn defaults Photoshop plays differently, keep resets and both colours (#4496)
The PR stopped writing PhotoCraft's defaults for params a step leaves
out. Checked against Photoshop, that is fine for Fill opacity and mode,
Crop delete, Selective Color method, Channel Mixer monochrome, Paint
Bucket contiguous and Gradient Map reverse/dither, but Photoshop plays
other missing keys differently: Canvas Size anchors top left (PhotoCraft:
centre), Apply Image blends Normal (Multiply), Color Balance turns
preserve luminosity off (on), and a Paint Bucket step without a
tolerance doesn't play at all. Those, and the unverified Hue/Saturation,
Color Balance tone and Save for Web keys, are written again as before.

- `tools.setColors` exported only the foreground of a step that sets
  both colours; such a step now doesn't export (the strict save refuses
  it, as before the PR).
- `kind` on an imported `layer.setAdjustment` made Photoshop's "reset to
  default" (`set` with an empty `T`) a no-op merge; it now joins only
  params that edit something.
- `cargo xtask corpus --atn` (and `--all`) failed on Windows: the Bjango
  archive has a macOS `Icon\r` file tar can't create there; it's skipped.
- `test-corpus --changed` now runs for `atn_cmds` changes, and the
  `ALL` doc comment is back on `ALL`.

Tests: defaults_photoshop_plays_differently_are_written,
set_colors_exports_only_a_single_colour and
adjustment_layer_reset_still_resets fail without this commit; the psd
and engine .atn corpus tests still pass (16 files byte-identical; 513
kept Photoshop steps).

Co-authored-by: Fabio Lissi <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 02:28:58 +02:00
567a342296 Integration: Color Table presets after Load, .act by file name (#5407)
- Color Table dialog: after Load…, picking a preset in the Table menu still
  applied (OK) and saved (Save…) the loaded colors, as the hidden `colors`
  field won over the menu (`image.mode.colorTable` applies `colors` over a
  preset). The dialog now drops the loaded colors from the fields OK sends
  once a preset is picked; Save… uses them only while the menu is on Custom.
- Swatches import from bytes (browser, drag and drop) sniffed the format by
  content, and .act has no signature, so an .act whose first color is
  00 01 xx or 00 02 xx went to the .aco parser and failed. A file named
  .act now imports as .act; .aco and .ase are still detected by content.
- Removed "Export Swatches as Color Table…" from the Swatches menu: Photoshop
  saves .act from Image > Mode > Color Table, not from the Swatches panel.

Co-authored-by: Abdul karim mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 02:26:18 +02:00
David MannandClaude Opus 5.5 c9b0bb0cbc Integration: merge main, resolve conflicts (#4496)
fill_to (Paint Bucket export): keep main's `source` alias and explicit
colour (`Usng` = `Clr ` plus `Clr `, #7199) together with the PR's
optional `Tlrn`/`AntA`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 02:05:34 +02:00
b484f45429 Integration: merge main, resolve conflicts (#5407)
apps/photocraft{,-web}: main added "cin" to OPEN_EXTS; kept it and the PR's
"act". apps/photocraft/src/services.rs: main gave File > Open one filter per
format (#6337) and checks with every_open_format_has_its_own_filter that the
filters cover OPEN_EXTS exactly, so "act" joins the Swatches filter.
i18n: both sides appended sections at the end of every catalog; kept both.

Co-authored-by: Abdul karim mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:49:54 +02:00
399e6a43c5 Integration: repair Gradient Maps saved by older builds on re-save (#8081)
Older builds wrote every generated `grdm` 2 bytes short of four-byte
alignment (86 + 20n bytes), which Photoshop refuses. The PR pads new
blocks, but opening such a file and saving it again kept the old block:
the keep and Reverse/Dither retarget paths reuse any `grdm` that parses
to the same Gradient Map. Such a block is now regenerated, like the
legacy `phfl` repair. Only blocks that are byte for byte the old writer's
output are matched, so other writers' blocks (stops in CMYK/Lab/Gray)
are still kept verbatim.

Test: legacy_unaligned_gradient_map_is_repaired_on_resave (146 bytes
without this change).

Co-authored-by: Sir Starch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:35:14 +02:00
d149007d0b Integration: merge the author's rebased head into the fix-up (#4935)
The author rewrote the branch to a75039792e (the same Duotone code as
b10c9b91f1, without its main merge). This merge puts their new head
into the fix-up unchanged; the tree is identical to 2d7c62055e.

Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:24:28 +02:00
152c9677da Integration: keep unreadable canonical type as a type layer (#4771)
A normal big-endian TySh that the typed reader rejects (an unknown text
descriptor version, a stub from another writer) opened as a text layer on
main, with the best-effort text and its TySh saved back verbatim. With the
PR it became a raster layer, and the PSD export strips TySh from rasters,
so open + save turned the type layer into pixels for Photoshop. Now only
foreign-order blocks and unsafe transforms become pixels; the old fallback
runs under the same transform gate (shared as
photocraft_text::psd::transform_is_safe). The warning no longer claims the
type data is kept.

Test: canonical_type_the_typed_reader_rejects_stays_type_and_saves_its_block
(descriptor version 17, ordinary matrix) fails without this change.

Co-authored-by: Sean Lavery <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:14:50 +02:00
David MannandClaude Opus 5.5 d41aae7849 Integration: merge main, resolve conflicts (#4771)
crates/text/src/psd.rs: keep both appended test modules (transform_safety_tests
and main's nonfinite_tests). docs/file-format-parity.md: add the #4758 item to
main's current change line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 00:54:54 +02:00
Sir Starch d1f6ab4f1b fix(psd): pad generated Gradient Map blocks (#8070) 2026-10-21 00:41:02 +02:00
2f002a4545 Integration: pin Overprint's PSD read/write, translate its label (#6002)
lfx2_roundtrip_all_kinds compares by writing both sides again, so a reader
or writer that drops FrFX `overprint` on both sides still passes. The new
test checks the parsed flag for off and on and for a stroke without the key
(other writers); it fails with either the reader or the writer reverted.

The new "Overprint" checkbox label had no row in any of the 20 catalogs, so
every non-English UI showed it in English, unlike its translated siblings.

Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 00:37:47 +02:00
David MannandClaude Opus 5.5 627bb8f1bf Integration: merge main, resolve conflicts (#6002)
docs/file-format-parity.md: both sides added revision-history rows; keep both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 00:11:01 +02:00
Vikram Bhaskaran ff61346024 Open mislabeled .psd files from their raster signature.
PNG/JPEG/TIFF bytes named .psd still go through codecs, as on main and
in Photoshop. Only an unrecognized .psd keeps the PSD parse error.
2026-10-21 08:57:47 +11:00
Gert Pellin a75039792e Layered TIFF corpus test: a flat Duotone is expected with its inks rendered
With the ink block read, an opened duotone has its real inks. A
single-layer one is saved flat, which writes the inks rendered as RGB
(flat.rs); the test compared that with the grey plate.

Refs #4814
2026-10-20 23:56:32 +02:00
Gert Pellin 60c05073a5 PSD Duotone: read and write the ink block (names, colours, curves)
The Duotone Color Mode Data is undocumented. From files Photoshop 2025
made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as
in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four
transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20,
..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain
and eleven overprint colours.

- Import interprets the inks, so an opened duotone shows them instead of
  its grey plate; the block is still kept raw and written back while the
  inks are unedited. A colour-book ink (not readable here) shows as black
  with a warning.
- Export writes a block for duotones made in PhotoCraft (RGB ink colours,
  curves on the 13-point grid or sampled at it, default dot gain,
  computed overprints), so every 8/16-bit Duotone is saved as Duotone.

Photoshop opens such files as Duotone, and a black monotone whose curve
is 0 up to 60 % density stays white exactly up to 60 % there.

Refs #4814
2026-10-20 23:56:28 +02:00
0073b395d4 Integration: merge main, resolve conflicts (#5990)
docs/file-format-parity.md: main added three 2026-10-15 revision rows
(DDS save, PNG sTER, PSD guides) where the PR added its own; keep all four.

Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:50:13 +02:00
723dbe943f Integration: test that PSD export writes antialiasGloss (#5990)
lfx2_roundtrip_all_kinds compares re-serialized bytes, so dropping the
new `.with("antialiasGloss", ...)` line from write_one still passed every
test: both sides then lose the flag the same way. The new test writes a
Bevel with the Gloss Contour's Anti-aliased on and reads the flag back;
it fails with that line removed.

Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:49:52 +02:00
2d7c62055e Integration: Duotone colour-book inks, 8-bit only, curve ends (#4935)
- A PANTONE (colour-book) ink is stored by its book key, so it read as
  black: Photoshop's usual Black + PANTONE duotones showed, exported flat
  and converted to RGB much darker than the grey plate before. Photoshop
  writes every ink's Lab equivalent in resource 1066; it now gives the
  ink its colour. Without it, the grey plate is shown, as before.
- Ink colours go through channel_map::decode_known_color (HSB inks were
  black with a "colour book" warning; CMYK is colour-managed).
- Photoshop has only 8-bit Duotone and refuses to open a 16-bit Duotone
  PSD: a 16-bit Duotone is written as Grayscale with the warning again.
- A curve on the 13-point grid without a 0 % or 100 % point was written
  with -1 there, which Photoshop refuses to open: both ends are filled.
- A layered TIFF of an Indexed, Bitmap or Duotone document says it is
  written in its pixel format (CodeRabbit's thread at tiff_layers.rs).

Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:46:51 +02:00
David MannandClaude Opus 5.5 3b63a27a88 Integration: merge main via the #4816 fix-up, resolve conflicts (#4935)
#4935 is stacked on #4816. Merging our reviewed #4816 fix-up (baa854341b,
which already contains origin/main) brings both up to date; no conflicts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:26:58 +02:00
Sir Starch 480c12304d Pencil: align click and line boundary snapping (#4895) 2026-10-20 23:00:18 +02:00
Matthias Schlich d104f7015f fix(filters): Clouds and Fibers move with their smart object
Their noise was sampled in document coordinates, so moving a smart object re-rendered a
different texture with the same params. The pattern is now anchored at the filter's bounds:
the object for a smart filter, the canvas on a layer. Fibers, like Clouds, takes the canvas
as its bounds even with a selection, which only masks it, so layer results stay as before
(CodeRabbit review on #6160).
2026-10-20 22:49:04 +02:00
Sir Starch 3bd2274aa2 docs(codecs): document JPEG metadata helpers 2026-10-20 22:47:20 +02:00
Vikram Bhaskaran c0eb60dc84 Merge origin/main into fix/5258-psd-open.
Keep PSD routing in io. Use main's per-format Open filters, which
already include Photoshop.
2026-10-21 07:31:19 +11:00
Sir Starch f10194c028 Fix: omit JFIF APP0 from CMYK JPEG (#8008) 2026-10-20 22:29:51 +02:00
Matthias Schlich 0a7325af6e Merge origin/main into feat/psd-render-smart-filters
# Conflicts:
#	docs/file-format-parity.md
2026-10-20 22:20:37 +02:00
Sir Starch e81fa94f3f BMP export: use the full palette for opaque pixels 2026-10-20 19:37:31 +02:00
Sir Starch d470faa65f Merge remote-tracking branch 'upstream/main' into fix/liquify-freeze-boundary-7989 2026-10-20 19:11:40 +02:00
Sir Starch 45ed3ccb2e fix(liquify): keep frozen pixels stable at mask boundaries (#7989) 2026-10-20 19:11:36 +02:00
Sir Starch 0ed0f286ce Merge upstream/main into fix/bmp-indexed-export 2026-10-20 19:10:21 +02:00
Elvis33LE d22b6ec7b4 automation: edit.installedProfiles returns installed profiles, not host paths
The workspace test's filesystem-path classifier reads the command's
registry description, which mentions profile paths; the command takes no
host filesystem path as input, so it joins the allowlist beside
prefs.get/prefs.reset.
2026-10-20 19:04:18 +02:00
Elvis33LE 40b3bf9b6b test(gpu): reword the tile-growth doc comment for what main does
The comment still described the reverted encoder-batching design; on main
keep_overlap submits its own copy, so the pinned property is that the
kept overlap never overwrites tiles rewritten in the same update,
whatever the submission shape.
2026-10-20 19:02:51 +02:00
Higgs32584andClaude Opus 5.5 f6ed751a27 Open: a file without type never waits for the font scan (#7332)
The shell checks an opened document's missing fonts on the UI thread.
missing_fonts locked the shared text engine even for a document with no
type layers, and the engine's first use waits for the installed-font scan
started at launch, which takes minutes on some machines: the window froze
at 2% or 95% on the first open after every launch. A document without type
now returns at once, and a background open with type layers waits for the
scan on its worker ("Loading fonts") so the later UI-thread check is quick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:02:39 -04:00
Higgs32584andClaude Opus 5.5 b19293d293 Windows on ARM: auto picks DirectX 12 for Qualcomm graphics (#7537)
On a Windows ARM64 PC the Qualcomm Vulkan driver lost the device
mid-session (Layer Style > Inner Shadow edits), the window's egui-wgpu
renderer panicked on the lost device and PhotoCraft exited. With the
backend set to DirectX 12 the reporter could not reproduce it.

`auto` already ranks DX12 before Vulkan for Intel on Windows. Extend that
rule to Qualcomm, matching both the id its Vulkan driver reports (0x5143)
and the one DXGI reports ("QCOM", 0x4D4F4351). System Info names the
maker in its note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 12:59:22 -04:00
nrq6993-a11yandCursor 1bd60ab6fb Use catalog keys for Color panel slider mode labels (#7317).
tl!("HSB") and tl!("RGB") are missing from some locale TSVs; reuse HSB/HSL and RGB Color so every_tl_literal_is_translated passes.

Co-authored-by: Cursor <[email protected]>
2026-10-20 19:38:15 +03:00
xsvm 67a9c53f26 test(raster): build the test pixel without u8 overflow 2026-10-20 23:45:38 +08:00
xsvm a27fa7a19b perf(raster): tile content bounds compare rows block by block 2026-10-20 23:45:38 +08:00
Korg1ch 4d6d735ff9 test(io): developed CR2 and RW2 raws carry their EXIF, without orientation or pixel dimensions (#6957) 2026-10-20 18:28:13 +03:00
Korg1ch 7296d0d8b2 fix(io): a developed camera raw keeps the raw's EXIF for File Info's Camera Data (#6957) 2026-10-20 18:28:13 +03:00
jlange 566bccf6bb Merge remote-tracking branch 'origin/main' into vanishing-sample-alloc-7491 2026-10-20 16:17:11 +02:00
Korg1ch 5f8b124efa test(ui-egui): Slice Select's icon differs from Object Selection's and Slice's (#7859) 2026-10-20 16:27:12 +03:00
Korg1ch 5634b5155a fix(ui-egui): Slice Select gets its own icon, the Slice knife with a pointer (#7859) 2026-10-20 15:50:02 +03:00
NetterSilverandClaude Haiku 5.5 ff2db1b83b Fix File Info showing 1/1 s for exposures just under one second
Found while testing crates/engine/src/file_cmds.rs:768

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 14:21:14 +02:00
Korg1ch 52312331d8 test(engine): Emboss saves as Embs with integer Angl/Hght/Amnt and loads back (#7946) 2026-10-20 14:56:00 +03:00
Korg1ch 38611da546 fix(engine): .atn save and load map Filter › Stylize › Emboss to Photoshop's Embs (#7946) 2026-10-20 14:56:00 +03:00
wstockandClaude Fable 5.1 e51f48d0e8 Zoom tool menu: Zoom In and Zoom Out last, as in Photoshop (#6005)
The Zoom and Hand tools' context menu put Zoom In and Zoom Out first.
Photoshop's lists Fit on Screen, 100%, 200% and Print Size, then Zoom In
and Zoom Out after a separator, which is where users look for them. The
rows are reordered (Fill Screen keeps its place by Fit on Screen); the
order test follows.

The control.rs agent test (ui.inspect over the Zoom tool's menu) and the
two context-menu docs now state the new row order.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-20 11:32:37 +00:00
Korg1ch 9819df5002 test(ui-egui): clicking a note shows its author and colour (#7940) 2026-10-20 13:59:18 +03:00
Korg1ch d26d32cafe fix(ui-egui): a selected note shows its author and colour in the Note options bar (#7940) 2026-10-20 13:59:17 +03:00
Korg1ch 4fea6ffda4 test(ui-egui): Fixed Size fields show and take the unit picked on them (#7910) 2026-10-20 12:39:05 +03:00
Korg1ch 53c3ff543b fix(ui-egui): Fixed Size marquee width and height take any length unit (#7910) 2026-10-20 12:39:05 +03:00
Khawar Zamman WaniandClaude Opus 5.5 d51202c5a4 Smart Blur: 3.5x faster at radius 12 with identical output (#7921)
Neighbours are read at precomputed offsets into the window instead of
per channel through bounds-checked Image::get. Normal mode with 1-5
channels runs the interior (whole kernel in the window) offset-major
over each row with a branchless select: a rejected neighbour adds -0.0,
an exact identity, and every pixel sums in the same order, so the
output is bit-identical. Border pixels, the edge modes and 6+ channels
keep the per-pixel path.

24 MP RGBA8, High quality: r3 0.55 -> 0.23 s, r12 6.25 -> 1.78 s,
r100 3.64 -> 0.95 s. bench_filters2 gains r12 and r100 rows.

The old kernel stays as a test oracle, compared bit for bit across
colour modes, 1-8 channels, alpha, radii, qualities, modes, thresholds,
output rects and NaN/inf samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:50:17 +05:30
Andee CollardandClaude Opus 5.5 69af55ff7e gallery_sheet: reject an unknown GALLERY_ONLY filter or GALLERY_SET key
A misspelt filter rendered nothing and a misspelt setting rendered the
default, both silently (CodeRabbit review). Also documents apply and the
halftone test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 07:27:36 +01:00
nrq6993-a11yandCursor cf8a79190d Apply rustfmt to capture_key_press unit test call site.
Co-authored-by: Cursor <[email protected]>
2026-10-20 09:04:17 +03:00
Andee CollardandClaude Opus 5.5 53e3a059ee Filter Gallery › Halftone Pattern: a tone-keeping screen at 45°
Every pixel was pure ink or paper at any Contrast, dots could never fill
their cell (white crosses in the shadows), mid-greys came out the wrong
darkness and the dot grid was square to the image.

Halftone Pattern now keeps a continuous range of tones, as Photoshop
describes it:
- each pattern is a threshold (spot) function spread evenly over its
  cell: round dots on a 45° screen that grow from the centre, meet as a
  checkerboard of squares at 50% and fill the cell; rings round the
  image centre; horizontal lines
- each tone is offset (closed form, a 256-step table per Contrast) so a
  cell's average ink is exactly that tone: black stays solid and white
  stays paper at any Contrast
- Contrast is how steep the threshold is: soft "blurry squares" at 0,
  crisp dots at 50
- Size 1 is a fine screen (3.5 px, 25.5 px at 12)

The gallery_sheet dev test can render one filter with chosen settings
(GALLERY_ONLY, GALLERY_SET, GALLERY_TAG).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 07:03:55 +01:00
nrq6993-a11yandCursor 5be2b29463 Merge upstream/main into stream/issue-5902-text-antialias.
Resolve render.rs by routing parallel composite through shape_antialias_coverage.

Co-authored-by: Cursor <[email protected]>
2026-10-20 08:11:03 +03:00
nrq6993-a11yandCursor a99277109c Document #7190 shortcut-capture root cause and unit-test capture_key_press.
Co-authored-by: Cursor <[email protected]>
2026-10-20 07:55:10 +03:00
nrq6993-a11yandCursor 034788019e Restore top-level group auto-open during brush search (#7270).
Keep nested folders collapsible while filtering (#7257); only preset_list top-level headers force-open when the filter is non-empty.

Co-authored-by: Cursor <[email protected]>
2026-10-20 07:50:56 +03:00
wwewtech 824f93a69f fix(atn): a preset-only Photo Filter step exports its resolved colour (#7235) 2026-10-20 07:20:08 +04:00
Hector VillarrealandClaude Opus 5.5 e1a7065071 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 18:14:34 -06:00
Michael Higgins 468b96d57d Merge branch 'storytold:main' into fix/321-new-document-color-profile 2026-10-19 19:34:27 -04:00
Hector Villarreal dbdd8ff66e Merge remote-tracking branch 'upstream/main' into fix/7316-color-chips-centre
# Conflicts:
#	crates/ui-egui/src/panels.rs
2026-10-19 17:32:18 -06:00
JGF 65879ba0fb Shortcuts: Ctrl+F4 closes the document beside Ctrl+W (Windows/Linux)
Photoshop on Windows closes the current document with Ctrl+F4 as well as
Ctrl+W. PhotoCraft bound only Ctrl+W, so Ctrl+F4 did nothing.

Add ("file.close", "Cmd+F4") to prefs::ALTERNATE_SHORTCUTS off the Mac
(macOS keeps Ctrl+F4 for window focus, and Photoshop for Mac has no such
key). It goes through the same guarded Close, so unsaved changes still
get the save prompt; the test checks both cases.
2026-10-19 18:43:25 -04:00
Hector VillarrealandClaude Opus 5.5 8f0a484298 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:22 -06:00
Hector VillarrealandClaude Opus 5.5 9f1083d31e Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:15 -06:00
Hector VillarrealandClaude Opus 5.5 b3224447a9 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:12 -06:00
Hector VillarrealandClaude Opus 5.5 d824505598 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:08 -06:00
Omar Saidahmed ab16e0b4b7 document the shared hsb conversions 2026-10-19 17:59:37 -04:00
Brian TorresandClaude Opus 5.5 d11f2b6c84 Free Transform: the test budget fits 2^20 px at any depth (#7807)
The cfg(test) budget was 4 MB, 2^20 px of 8-bit RGBA only, so the turned
crop test that landed in #5007 (an 862 x 803 px layer at 16 and 32 bits)
was refused. It is now 2^20 px of the widest format (32-bit CMYK with
alpha, 20 MB), so whatever the old 2^20 px test limit let through still
fits; the refusal tests stretch far enough to stay over it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:26:40 -07:00
Peppe 2e845117b9 feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 22:56:06 +02:00
Michael Higgins 16154d2cff Merge branch 'storytold:main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-19 16:42:55 -04:00
Peppe 3c6d1088a1 test(ui): the font menu's search field stays put while the list scrolls (#7792) 2026-10-19 22:33:23 +02:00
Ian Swope 769cd2f725 Warp: Esc also stops the warp of a selected linked mask
The mask's warp under a selection ran with Interrupt::NONE, so a cancelled
job still finished it. It now gets the job's interrupt, as the pixels do.
2026-10-19 15:14:04 -05:00
Michael Higgins 5a17d92f03 Modify resume action to discard result
Change resume action to ignore the result.
2026-10-19 15:51:30 -04:00
Michael Higginsandcoderabbitai[bot] 4a53af3598 Update crates/io/src/flat.rs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-19 15:45:34 -04:00
NetterSilverandClaude Haiku 5.5 8591ca1904 Scale layer-effect noise by coverage so it follows the glow
Fixes #7833

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:44:44 +00:00
Brian TorresandClaude Opus 5.5 c5ba77e5d9 Free Transform: refuse a result the warp would clip, not only one too large (#7807)
The warp places pixels within +-2^20 px (WARP_REACH) and clamps the rest
away. The byte budget admitted results wider than that (a 2,000,002 px
strip fits 8192 MB), and a plain move past it already emptied the layer
without an error. `warp_output_clipped` says when the result would be cut,
and the budget check refuses it: "would reach past +-1048576 px, the
farthest a transform places pixels". Docstrings for the touched functions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:20:03 -07:00
Brian TorresandClaude Opus 5.5 f1a3e61268 Copy: the notice text copies after the pointer leaves it too; test docs (#7807)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:15:32 -07:00
Brian TorresandClaude Opus 5.5 a5ebdaaa30 Free Transform: a result may take what Memory Usage allows, not 2^30 px (#7807)
The fixed 2^30 px cap refused any transform of a stitched layer past about
1 GP, on any machine. A result is now priced in bytes at its pixel format
(with the alpha the warp adds) against Preferences > Performance > Memory
Usage (default 8192 MB), which Session::apply_prefs sets. The report's
37708 x 35259 px 8-bit result (5072 MB) now transforms: 10.7 s, 6.1 GB on
top of the source. Past the allowance the message names it:
"... (10144 MB), more than the 8192 MB Memory Usage allows (Preferences >
Performance)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:35 -07:00
Brian TorresandClaude Opus 5.5 a0983e6105 Free Transform: the warp writes each tile as it finishes (#7807)
Every finished tile kept its f32 pixels until the last one was done, 16
bytes a pixel of an RGBA result on top of the result itself. Writing each
tile as soon as it is warped holds only the tiles in flight.

100 MP 8-bit layer (release, M4 Max, 16 cores), extra memory per result pixel
and time: scale 110 % 16.4 -> 4.8 B, 0.8 -> 0.6 s; rotate 15 deg 12.5 ->
3.4 B, 0.8 -> 0.5 s; 90 % + 15 deg bilinear 12.7 -> 3.4 B, 0.5 -> 0.3 s;
90 % + 15 deg bicubic 19.9 -> 12.4 B (its pre-reduced copy of the
source), 1.0 -> 0.8 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:15 -07:00
Brian TorresandClaude Opus 5.5 818de14222 Free Transform: document the budget check and its refusal test (#7807)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:08 -07:00
00xJ0 5faf499d5c Shortcuts: custom bindings on PageUp/PageDown (and other multi-word keys) fire again (#6827)
normalize_shortcut title-cased every named key, so a binding set to Shift+PageUp was saved as Shift+Pageup, which egui's case-sensitive Key::from_name rejects: the shortcut never fired. Multi-word key names (PageUp, PageDown, Open/CloseBracket, Open/CloseCurlyBracket, BrowserBack, IntlBackslash) now keep egui's spelling, and shortcuts::parse matches key names case-insensitively so bindings saved by earlier versions work too.
2026-10-19 12:05:57 -07:00
Peppe bff2609a68 feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 21:05:10 +02:00
00xJ0 abd3755d41 RecoveryStore::flush keeps each entry's key, so a later discard still removes it (#6464 review) 2026-10-19 12:05:01 -07:00
Peppe 7c504d79c2 Merge branch 'main' into feature/7792-search-font-sticky 2026-10-19 21:04:21 +02:00
Brian TorresandClaude Opus 5.5 8b083b6e5b Copy: text selected in an error notice is copied, not the layer (#7807)
Outside text fields the clipboard keys go to the document, so ⌘C/Ctrl+C
over text dragged across in a notice (where a refused Free Transform's
message lands) ran Edit › Copy on the layer and the message was never
copied. Text selected in a label (dragged across, double/triple- or
shift-clicked) now takes ⌘C, as egui-winit's Copy event or as the Mac
menu's key press; ⌘X and ⌘V stay the document's, and a plain click on a
label leaves ⌘C to Edit › Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:45:35 -07:00
Korg1ch ec0fc7cb0e test(atn): strict export refuses Minimum / Maximum with Roundness; Squareness round-trips (#7233) 2026-10-19 21:37:45 +03:00
Korg1ch 59b03f2948 fix(atn): a Minimum / Maximum step with Preserve: Roundness is not exported as Squareness (#7233) 2026-10-19 21:37:21 +03:00
Korg1ch b287a884f4 test(engine): a job applied during a later recording is not recorded there (#7289) 2026-10-19 21:36:24 +03:00
Korg1ch 07b9600561 fix(engine): a background job's step goes only to the recording it was started in (#7289) 2026-10-19 21:36:23 +03:00
Michael Higgins ceaac3dc25 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-19 14:32:43 -04:00
Michael Higgins a66e6ed0bb Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-19 14:31:58 -04:00
Michael Higgins 2c96d9faa0 Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-19 14:31:41 -04:00
Michael Higgins fa9f11c7a7 Merge branch 'storytold:main' into fix/2095-android-menus 2026-10-19 14:31:10 -04:00
Michael Higgins c9889c8128 Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-19 14:30:53 -04:00
Brian TorresandClaude Opus 5.5 475df78c32 Free Transform: a result over the pixel limit says so, not "not enough memory" (#7807)
The 2^30 px refusal went through allocation::error, so it read "not enough
memory for transforming pixels (...)" although nothing had been allocated.
It now says the result is too large to transform and names the limit.
A real allocation failure still says "not enough memory".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:30:21 -07:00
Hector VillarrealandClaude Opus 5.5 b960c3a4e2 Type move zone: leave the paragraph box to the caret (#7606)
Inside a paragraph box, off the glyphs, a press places the caret, as in
Photoshop: the move zone now excludes it. The reach drops from 40 to 38 pt
so a press 40 pt outside a paragraph box still commits the text (#7606's
test). Photoshop measured 35 pt moving and 48 pt committing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:03 -06:00
Michael Higgins 34b5754efd Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-19 14:09:18 -04:00
Michael Higgins cd781bb25f Merge branch 'storytold:main' into fix/2095-android-menus 2026-10-19 14:08:55 -04:00
Michael Higgins e4546cc0bc Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-19 14:08:41 -04:00
Michael Higgins 84a1bf37f2 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-19 14:08:06 -04:00
Michael Higgins f9d883b5de Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-19 14:06:54 -04:00
Peppe 806e7ba37a feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 20:05:37 +02:00
Peppe 9af604e90c Merge branch 'main' into feature/7792-search-font-sticky 2026-10-19 20:05:01 +02:00
Hector Villarreal 4490717400 Merge remote-tracking branch 'upstream/main' into fix/5956-type-move-near-text 2026-10-19 12:02:31 -06:00
Peppe a58a6b100f feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 19:53:29 +02:00
Hector VillarrealandClaude Opus 5.5 4b9fde8787 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:59 -06:00
Hector VillarrealandClaude Opus 5.5 dc6cb9725b Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:53 -06:00
Hector VillarrealandClaude Opus 5.5 f4764f1f99 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:50 -06:00
Hector VillarrealandClaude Opus 5.5 a9a757543d Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:46 -06:00
Hector VillarrealandClaude Opus 5.5 a84aa1be34 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:26 -06:00
jlangeandClaude Opus 5.5 21fa6cf1dd Vanishing Point: read a sample's 2×2 in one call outside the window (#7491)
When the source window is skipped (cloning near to far toward the
horizon), four `read_pixel` calls looked the tile up four times and made
that regime ~20% slower than `main`. One `read_region_into` into the
reused corner buffer looks it up once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:06:14 +02:00
Hector VillarrealandClaude Opus 5.5 21cca70465 Glow Range: document the 1..100 % range its slider and clamp use
The outer and inner glow commands documented range as 0..100, while the
slider starts at 1 % and the command holds values below it at 1 %, as
Photoshop's Range does. Document 1..100=50 (like Bevel's contourRange) and
test the boundaries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:38:51 -06:00
Rene Leonhardt 05bd004bc4 perf(format): reuse native save writers per document
Reuse unchanged tile and blob hashes and bound retained compressed payloads. Carry the writer through native save settings while preserving save identity and exact output.
2026-10-19 16:58:35 +02:00
Gert Pellin b10c9b91f1 Merge branch 'main' into feat/4814-duotone-inks (via feat/4814-psd-palette-modes) 2026-10-19 16:33:25 +02:00
jlangeandClaude Opus 5.5 4277f12437 Vanishing Point: skip the source window read when it is disabled (#7491)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:44:01 +02:00
Hamza f309665694 Refuse a Selective Color channel the command would refuse
The scalar form's channels went through `jnum`, which returns `None` for
anything that is not a number, so `{"colors": "reds", "cyan": "35"}` exported as
an untouched range while the command errors on it
(`adjust_cmds::selective_from_params` → `percent`). The export would have
written an adjustment that is not the one recorded.

A channel that is present and not null must now be a finite number, or the step
cannot be written and takes the refusal path. `null` counts as absent, as the
command's `given` reads it.
2026-10-19 18:29:33 +05:00
PerchAnton 674bfb697b docs(atn): document Ripple field conversion and test helpers 2026-10-19 16:27:06 +03:00
Hamza 1f6dbf8415 Lasso: a short straight segment places a vertex instead of closing the outline
With the Lasso and Alt held (Photoshop's straight-segment mode), a click closer
to the last vertex than the radius for grabbing the first one ended the
selection instead of placing the vertex:

  three Alt clicks at (50,50) (90,50) (90,90)  ->  3 vertices
  a fourth at (85,91), 5 points away           ->  the selection was committed

The radius is `8.0 / point_zoom`, so every segment shorter than 8 points on
screen was impossible to draw -- the click meant to continue the outline
finished it, and tracing anything small ended the selection after three
vertices (#7629).

Both closing gestures stay; they stop sharing one radius. Clicking the first
vertex is aiming at a target and keeps the 8-point grab radius. Clicking the
last vertex again is the same click repeated, so it only has to allow for a
hand's jitter: 3 points. Enter still closes, Escape cancels, Backspace and
right-click retract, releasing Alt with the button up closes, and the Freeform
Pen still ends an open path by clicking its last point.

I could not reproduce this on the dedicated Polygonal Lasso: `polygon_click`
closes only near the first vertex, and egui counts a double click by the time
between clicks *and* their distance (`max_click_dist`, 6 points), so two quick
clicks further apart are not one. A probe placing a vertex 4 units from the
last, clicked in the next frame, keeps it.
2026-10-19 18:17:25 +05:00
jlangeandClaude Opus 5.5 e87ad39a21 Vanishing Point: perspective clone samples without allocating per pixel (#7491)
`sample` read a 2×2 region (plus a default-pixel Vec) and collected a new
Vec for every covered pixel of each dab. It now writes into caller-owned
scratch and reads its corners from the dab's source footprint, read once per
dab; corners outside it come from `read_pixel`. Output is unchanged; corner
coordinates saturate instead of overflowing at the edge of i32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:14:33 +02:00
Ian Swope a053f9bc4e Warp: apply a large layer's warp in the background, with progress and Esc to cancel (#1015)
Committing Edit > Transform > Warp ran on the UI thread: on an 8000x6000
layer the window froze for 6.9 s (Arc) or 9.3 s (Flag), with no progress or
cancel. edit.transform.warp now runs its edit through edit_job, as Free
Transform does since #4218: the mesh warp reports progress per tile and
stops when cancelled, and the transform session keeps its preview until the
result lands. Undo during the commit names Warp.

Most of a Flag warp's time was after the mesh warp: re-converting a layer
that already had alpha (2.0 s) and compositing the warp over a cleared
layer that held nothing else (2.3 s). The layer is now cloned, and the warp
is the result when the box held all of it. Flag drops to 1.4 s and Arc to
4.2 s inline.
2026-10-19 07:18:29 -05:00
Hamza 7835d05a54 Actions: a Selective Color step keeps the values of its scalar form
`image.adjustments.selectiveColor` takes a range two ways, and the command reads
both: the array form `"reds": [c, m, y, k]`, and the scalar form that names one
range and gives the channels it changes, `"colors": "reds", "cyan": 35`.

`selective_to` looked only for the arrays. A step recorded the other way
exported with an empty `ClrC` list -- an adjustment that changes nothing -- with
`skipped` empty and no warning on import, so nothing said the values had gone.
On a single red pixel the step produces [0.6509804, 0, 0, 1] run directly and
leaves [1, 0, 0, 1] after a round trip through .atn.

The rows are now built the way `selective_from_params` reads them: the arrays
first, then the scalar form over the range it names, so a step that gives both
exports what playing it would produce. An unknown range in `colors` still
cannot be written and refuses the step.

Fixes #7672.
2026-10-19 17:11:27 +05:00
Rene Leonhardt f64a143c55 perf(format): drop unhelpful Zstandard workspace reuse 2026-10-19 13:43:20 +02:00
Rene Leonhardt f1ba4854d0 docs(perf): record end-to-end workspace results 2026-10-19 13:15:12 +02:00
PerchAnton caca15106e fix(actions): refuse lossy fractional Ripple ATN export 2026-10-19 13:27:37 +03:00
Rene Leonhardt 02a63c94c2 perf(format): Reuse Rust Zstandard workspaces
Keep fresh compression for partial blocks and verify independent frames against the previous encoder. Record the isolated allocation-reuse effect.
2026-10-19 11:31:59 +02:00
PerchAnton 0acb707fe1 fix(actions): preserve effective Stroke width in ATN export 2026-10-19 12:28:43 +03:00
Hector VillarrealandClaude Opus 5.5 9e22dda917 Test: a copy floated by the layer's own transparency is as solid as the original
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:26:36 -06:00
Hector VillarrealandClaude Opus 5.5 c1f20c239d Move tool: a selection lifts up to a pixel's own coverage (#7655)
Loading a layer's transparency as the selection and dragging it with the Move
tool left a ghost of a·(1−a). Photoshop lifts the selection's share of a
pixel's coverage, up to all of it, and leaves the rest, so its own
transparency lifts it whole. Opaque pixels split as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:07:47 -06:00
Hector VillarrealandClaude Opus 5.5 b8b8907f72 Type move reach: measure on screen with pixel aspect; leave other type to the Type tool
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:48:33 -06:00
Hector VillarrealandClaude Opus 5.5 060a8cced7 Pen resume: a stale draft prefers the path it came from over the selected one
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:48:31 -06:00
Hector VillarrealandClaude Opus 5.5 c642bfbe64 Pen resume: undo that drops a resumed draft clears its ⌘ selection
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:39:40 -06:00
Hector VillarrealandClaude Opus 5.5 00697fb777 Move typed text with a drag from just outside it (#5956)
While typing, a press outside the text committed it. Photoshop moves the
text instead when the press is just outside it (measured: 35 pt off the
glyphs moves it, 48 pt commits), and typing carries on. A plain press
within 40 pt of the text frame now starts the existing type transform as
a move, and the cursor shows Move there; further out a press commits as
before, and a paragraph box's handles still resize it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:25:38 -06:00
denstu473 dfb84a686d ci: trigger checks with current contributor status 2026-10-19 08:25:00 +02:00
Hector VillarrealandClaude Opus 5.5 199b0a122c Pen resume: a stale draft joins its source path; undo stops at its anchors
A draft whose subpath changed meanwhile is added to the path it came
from even with nothing selected in the Paths panel, instead of replacing
the Work Path. Undoing points in a resumed draft never removes the
path's own anchors: past the added ones it drops the draft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:21:34 -06:00
Hector VillarrealandClaude Opus 5.5 cd44042921 Pen resume: keep the subpath's operation, add a stale draft as a new subpath
A resumed subpath takes only the drawn anchors and closing, so its path
operation survives. The draft remembers its document and the subpath as
it was; if either changed before the commit, the drawing is added as a
new subpath instead of overwriting another one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:06:20 -06:00
Hector VillarrealandClaude Opus 5.5 f99e12ecbd Carry on an open path from its end with the Pen (#6593)
A Pen click on an endpoint of a finished open path started a new
subpath there; Photoshop carries on drawing that path. A press on the
first or last anchor of an open subpath of the selected (or work) path
now resumes it, reversed from its first anchor, and the commit replaces
that subpath in one step. Shape mode and a targeted vector mask, which
take new paths, are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:49:40 -06:00
Hector VillarrealandClaude Opus 5.5 0c27aea23a Open the selection menu on a Quick Selection right-click (#7268)
A right-click with the Quick Selection tool opened the Brush Preset
picker; Photoshop opens the selection menu, as for the other selection
tools. The canvas menu now covers Quick Selection, and the right-click
opens the picker only for tools without one. The options-bar brush chip
still opens the picker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:12:13 -06:00
Hector VillarrealandClaude Opus 5.5 5a5de0e73d Show Technique and Range in the glow Layer Style pages (#6698)
Inner Glow lacked Photoshop's Technique and Range, Outer Glow its
Technique, though the engine takes both. The pages now list them in
Photoshop 2025's order (Technique before the spread or choke, Range
after the contour), the dialog loads a glow's technique so OK keeps it,
and both glows' defaults carry Photoshop's factory technique and range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:46:14 -06:00
wwewtech afc817d1fa test(engine): a one-shot failure does not spend the counted failpoint 2026-10-19 07:51:51 +04:00
Diego Chagas f2a487b088 Flatpak: a second launch hands its files to the running PhotoCraft over a Unix socket (#1540)
#3755's hand-off listens on a loopback port, and every `flatpak run` gets
its own network namespace, so inside the sandbox the second launch could
never reach the first and opened a separate window. When FLATPAK_ID and
XDG_RUNTIME_DIR are set the primary now listens on
$XDG_RUNTIME_DIR/app/<app id>/instance.sock (0600), a directory every
sandbox of the app shares, and records "socket" instead of "port" in
instance.json. The lock, token, client cap, deadlines and reply are
unchanged; a crashed primary's socket file is unlinked under the lock,
and shutdown removes the socket.
2026-10-19 00:19:00 -03:00
heojunfoandClaude Opus 5.5 64e7ed1583 Studio: the floating Properties card has the Adjustments tab (#7265)
Window › Adjustments already selects Properties group tab 1, but Studio
themes float Properties as a card that only drew properties_body, so the
menu item (and the rail) opened the document's Properties and the
Adjustments grid could not be reached.

The card's title is now the group's pill tab strip, Properties |
Adjustments, as on the docked group: it follows and sets
dock_tabs.properties, honours hidden tabs, and its context menu closes a
tab or the card. The tab close logic moves out of the dock into
dock::close_tab so the card shares it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 10:43:28 +09:00
00xJ0 7bb7819b00 Lost window device: close only once every unsaved document is saved for recovery, retrying every 2 s; also check the device on the screen-picker frame (#6464 review) 2026-10-18 17:15:13 -07:00
Hector VillarrealandClaude Opus 5.5 01479be23a Toolbar: colour chips stay centred when the tools scroll (#7316)
In a window too short for the tools, the toolbar's tool column scrolls, and
egui widens the UI by the scroll bar. The colour chips (and Default and Switch
Colors with them) were centred in that wider UI, so they sat a bar's width
right of the tools: about 3 px in the Studio and Pro themes, more with
Classic's wider bar. They are centred in the column as it is before the tools
scroll now, as in a window tall enough not to scroll.

Tested in every theme, one and two columns, at 1x and 2x: the chips paint at
the same place in a 420 pt and a 1400 pt window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:41:16 -06:00
Omar Saidahmed fdb1794014 share hsb conversions between photocraft-color and the color picker 2026-10-18 19:21:31 -04:00
Higgs32584andClaude Opus 5.5 70eab0c230 Actions: stop when a Save step's document is closed by a Save Document script (#7367)
An imported Close with Save: Yes plays as `file.save` then `file.close` with no document.
A Save Document script event that closed the saved document left another document active,
and the Close then closed that one, unsaved edits and all. Playback now fails the Save step
when the document it saved is no longer open, so no later step acts on a fallback document.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:24:40 -04:00
Higgs32584andClaude Opus 5.5 bd5e5ed781 Artboards: move egui::Rect import into the tests (unused-import Clippy error)
Since pasteboard_quads returns [Pos2; 4], only mod tests uses egui::Rect,
so the non-test build failed clippy -D warnings with an unused import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:24:39 -04:00
jonathnwxh 86b4c67adf Expect post-commit best-effort preview cleanup on Unix 2026-10-19 05:37:19 +08:00
jonathnwxh 9c927993b5 Preserve omitted PCraft previews until manifest commit 2026-10-19 05:29:30 +08:00
Higgs32584andClaude Opus 5.5 8353ecc354 Merge upstream/main into fix/5712-artboard-pasteboard-hides-loose-layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:29:07 -04:00
00xJ0 e4d4ab4205 Presets: an Export As preset survives a damaged sibling set (test for #6906)
Extends a_damaged_preset_set_keeps_the_other_sets (#5602) to save an Export As preset, check it survives each damaged sibling set, and damage export_presets itself. Fails with export_presets: None in load_presets_json (the build break #6906 fixed), passes on main. Test suggested by dotCooCoo in #6906.
2026-10-18 14:28:21 -07:00
Korg1ch 2a9a35f038 test(menus): Ctrl+Alt+I then Alt up leaves the bar alone and Enter confirms Image Size (#7481) 2026-10-19 00:04:38 +03:00
Korg1ch 8c6166849a fix(menus): Alt held through a shortcut no longer puts the keyboard on the menu bar, so Enter reaches the dialog (#7481) 2026-10-19 00:04:38 +03:00
00xJ0 4615a8bc26 Survive a lost window GPU device: draw nothing, save unsaved work for recovery, close (#3438, #1215)
When the window's own wgpu device was lost (a driver reset, e.g. after sleep), egui-wgpu could
not draw another frame: a frame with shapes panicked inside it ("Failed to create staging buffer
for index data", #1215, exit 101, unsaved work lost), and a frozen window's close button opened
an unsaved-changes prompt nobody could see (#3438). The #243 fallback only covered the canvas.

- DeviceHealth::device_lost (wgpu reported the loss, not a recorded fault) and ::probe (a
  non-blocking poll that keeps running after a recorded fault).
- RecoveryStore::flush: finish queued autosaves and wait for them.
- gpu_status::check_window, at the end of every frame: once the window's device is lost the
  frame draws nothing; the first time every unsaved document is written to crash recovery
  (Services::flush_autosaves) and the app closes without asking. The next launch reopens them.
- The app watches the window device on the CPU canvas too; ui.gpu.simulateLoss {destroy: true}
  destroys the real device to drive the path.

Verified on an RX 6900 XT, Vulkan and DX12: destroying the device in the running app exited 101
with the egui-wgpu panic before, and now exits 0 with the document recovered on the next launch.
2026-10-18 12:53:48 -07:00
Higgs32584andClaude Opus 5.5 4ea30f3673 Background redraw: a kept composite holds its state weakly
Kept held an Arc<Document>, so up to KEEP old states (and every tile only they used) stayed
allocated outside Memory Usage after History dropped them. It now holds a Weak: a state still in
History (undo/redo, the eye toggled back) is found as before, by pointer or by equality with the
upgraded state, and an entry whose state is gone is dead and dropped at the next trim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:53:13 -04:00
Higgs32584andClaude Opus 5.5 4527b6a7e2 New Document: presets keep the Color Profile; a stale profile is cleared
- DocumentSettings gains an optional `profile`, so document.presets.save and Recent keep the
  Color Profile and command_params gives it back to file.new. Older presets load as the
  working space.
- Applying a saved preset drops the profile picked before, and a profile that doesn't suit the
  mode is removed from the dialog state, so Create never sends it.
- The rejected-profile test also checks the active document and its history are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:19 -04:00
Higgs32584andClaude Opus 5.5 8138fc12e7 test(web): a press, release and press in one frame keep all three events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:18 -04:00
Higgs32584andClaude Opus 5.5 56e8532c86 Actions: Select and Mask with Sample All Layers exports as smartBrushWorkspace
Export picked refineSelectionEdge, whose fields have no Sample All Layers, so the value was
dropped and re-import fell back to false. A step that sets it now exports as
smartBrushWorkspace, which keeps it; other steps still export as refineSelectionEdge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:17 -04:00
Higgs32584andClaude Opus 5.5 ecc8fb4622 HEIF: tag a BT.709 image with a linear transfer as linear sRGB
cicp_profile left every integer BT.709 image untagged (read as sRGB), including transfer 8
(linear), which cicp_rgb already maps to linear sRGB. Only the SDR transfers stay untagged now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:34:20 -04:00
Ni-zav 7240a69db1 merge: integrate queued screen capture cancellation with current main 2026-10-18 23:06:23 +07:00
Ni-zav fa5aac84c3 merge: preserve drag flyouts and upstream tool memory 2026-10-18 23:06:19 +07:00
Ni-zav 642baa07c5 fix(screen-picker): keep capture preparation invisible 2026-10-18 23:01:58 +07:00
abc2cbandClaude Opus 5.5 2a81d271b5 Merge upstream/main 4df773e9 into codex/github-smart-filters-usability-20261011
Conflicts and how they were resolved:
- crates/ui-egui/src/canvas.rs (ensure_filter_preview): kept our edit_command(), which
  also covers the Blending Options dialog and checks the target guard; main's
  smart_ui::confirm_command(fields, cmd, params) form (#6324) is the same preview
  (setParams on the proxy) without the guard.
- crates/ui-egui/src/filter_dialog.rs (preview_document_for_target): kept our scaling
  of the smart filters' lengths and the mask feather on the proxy (proxy_document
  already scales cache, placement and mask, so nothing is scaled twice); dropped
  main's run-at-full-size branch for setParams and its now unused
  SMART_FILTER_SET_PARAMS constant.
- crates/ui-egui/src/smart_ui.rs (open_editor): kept our open_editor (target guard,
  adjust_dialog then filter_dialog with reset_fields, then __smartFilter and the
  stored params) over main's edit_filter helper; adjustment edits keep their live
  preview (adjust_preview skips the layer path for __smartFilter), so main's
  __preview removal for __adjust is not needed.

Adapted to main's API: smart_ui::preview_command (main, #6324) now also returns the
Blending Options command for our blending dialog, so the preview window matches its
key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:52:02 +03:00
abc2cb f667f8204e Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-18 18:47:20 +03:00
abc2cb 90992f3f84 Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-18 18:47:20 +03:00
abc2cbandClaude Opus 5.5 957e68f600 Expand New Effects follows the destination of Transfer Effects
layer.layerStyle.transferEffects names the layer that receives the effects in
"to"; it has no "layer" param, so the disclosure target fell back to the active
layer and the wrong layer's effects were expanded or collapsed. Resolve "to"
for that command before recording new_fx_target. Every other Layer Style
command keeps resolving "layer" as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:47:20 +03:00
abc2cbandClaude Opus 5.5 4c0335612e Merge remote-tracking branch 'upstream/main' into codex/fill-mask-preview-20261012
Conflict in crates/engine/src/commands.rs (layer.newFillLayer.solidColor):
main now reads the colour with the checked color_param(cmd, p, key, default)?,
and this branch reads the Use Default Masks on Fill Layers option next to it.
Kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:46:18 +03:00
nrq6993-a11yandCursor 87ff8bbc66 Use a press menu for Color panel HSB/RGB mode (#7317).
The combo box was the nearest to the Color Lookup LUT picker in harness tests.

Co-authored-by: Cursor <[email protected]>
2026-10-18 18:29:08 +03:00
abc2cb f72c1d1688 Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012
# Conflicts:
#	crates/engine/src/layer_multi_cmds.rs
#	crates/ui-egui/src/layers_panel_ui.rs
2026-10-18 18:27:36 +03:00
abc2cb 6486ca76f4 Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-18 18:27:19 +03:00
abc2cbandClaude Opus 5.5 0e8840f157 Fill layers: document the functions and tests the PR adds or changes
CodeRabbit measured the docstring coverage of the PR at 78.75%, under its 80%
threshold. Add short doc comments to the functions #3975 adds or changes and
to the ones next to them, saying what each does and which behaviour each test
pins. The Layers panel functions of #3289 are left to that PR. Comments only;
no code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:55:32 +03:00
abc2cbandClaude Opus 5.5 a83ec3d2c9 Layers panel options: document the functions next to the ones the PR changes
CodeRabbit counts the functions around the lines a PR changes, not only the
changed ones, so the PR's docstring coverage stays under its 80% threshold
unless the untouched neighbours are documented too. Comments only; no code
changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:54:54 +03:00
abc2cbandClaude Opus 5.5 25b79243f1 Smart Filters: document the functions next to the ones the PR changes
CodeRabbit counts the functions around the lines a PR changes, not only the
changed ones, so the PR's docstring coverage stays low (59.49%, threshold 80%)
unless the untouched neighbours are documented too. Comments only; no code
changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:54:43 +03:00
abc2cbandClaude Opus 5.5 79793acefe Smart Filters: document the functions and tests the PR adds
CodeRabbit measured the docstring coverage of the PR at 59.49%, under its 80%
threshold. Add short doc comments saying what each helper does and which
behaviour each test pins. Comments only; no code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:50:28 +03:00
abc2cbandClaude Opus 5.5 f7942efd1d Smart Filter editor starts missing parameters from the spec defaults
open_editor opened the filter's dialog and copied the recorded parameters over
its fields. filter_dialog::open starts from the user's last-used values, so a
recorded filter that lacked a key (an older file, or a command that gained a
parameter) showed, and on OK saved, the remembered value instead of the
default. Reset the freshly opened filter dialog to the schema defaults, as
Alt+Cancel does, before the recorded parameters are copied in. Adjustment
editors are unchanged, and a plain Filter menu dialog still remembers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:49:38 +03:00
Korg1ch ccdf082c62 test(caf): erased sampling area previews as an error; a failed OK keeps the workspace open (#7384) 2026-10-18 17:49:09 +03:00
Korg1ch f0ef89baf6 fix(caf): an erased sampling area previews as an error, and a failed OK keeps the workspace open (#7384) 2026-10-18 17:48:53 +03:00
abc2cbandClaude Opus 5.5 7a671f1486 Layers panel options: document the functions and tests the PR adds
CodeRabbit measured the docstring coverage of the PR at 79.41%, under its 80%
threshold. Add short doc comments saying what each helper does and which
Photoshop behaviour each test pins. Comments only; no code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:41:58 +03:00
abc2cbandClaude Opus 5.5 792413b528 Expand New Effects follows the layer a Layer Style command changes
The disclosure target only read a numeric "layer" param, so a layer named by
its name (which every command accepts through explicit_layer) fell back to the
active layer and the wrong layer's effects were expanded or collapsed. Resolve
the target with commands::layer_param, which understands every explicit form
and still falls back to the active layer. layer.layerStyle.clear needs no case:
it leaves the layer without effects, which the existing post-run check already
excludes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:40:49 +03:00
abc2cbandClaude Opus 5.5 62dbd6ced7 Layers panel: thumbnails off no longer leave mask thumbnails that catch drags
starts_mask_drag reads the mask thumbnail rects recorded in egui memory on an
earlier frame. With the thumbnail size set to None no mask thumbnail is drawn
and nothing is recorded, so rects from when thumbnails were on stayed behind and
a drag of the row that started there was classified as a mask drag. Forget the
layer's recorded rects before the drag is classified when thumbnails are off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:40:48 +03:00
abc2cbandClaude Opus 5.5 3f5c65e166 Workspace layouts no longer save or restore the Layers panel options
The options persist on their own in the preferences (persist_options and
restore_options, as Photoshop's Panel Options do), and engine commands read
them from there. Restoring a workspace layout that carried the key put the UI
out of step with the preferences, so the panel and the commands disagreed.
Drop layersPanelOptions from the layout snapshot and from apply; layouts saved
by earlier builds keep the key, which is now ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:40:48 +03:00
Michael Higgins d90d4041df Merge branch 'main' into fix/5727-move-tool-offset-readout 2026-10-18 10:16:49 -04:00
Michael Higgins f43dac99f5 Merge branch 'main' into fix/5806-displace-asks-for-map 2026-10-18 10:16:44 -04:00
Michael Higgins be9672619f Merge branch 'main' into fix/321-new-document-color-profile 2026-10-18 10:16:00 -04:00
Michael Higgins 01e1f3f126 Merge branch 'main' into fix/6744-deselect-noop 2026-10-18 10:15:32 -04:00
Michael Higgins 7ce7a347f8 Merge branch 'main' into fix/6563-layer-texture-oom 2026-10-18 10:15:26 -04:00
Michael Higgins 95f5a19a0a Merge branch 'main' into fix/6752-heic-hlg 2026-10-18 10:15:08 -04:00
Michael Higgins 76ce0aa217 Merge branch 'main' into fix/6745-atn-event-mapping 2026-10-18 10:14:51 -04:00
Michael Higgins f1cfdf40f5 Merge branch 'main' into fix/6528-solo-restore-cache 2026-10-18 10:14:45 -04:00
Michael Higgins e9c1232ce7 Merge branch 'main' into fix/2095-android-menus 2026-10-18 10:14:40 -04:00
Michael Higgins 1158c79c87 Merge branch 'main' into fix/6865-selection-not-dirty 2026-10-18 10:14:30 -04:00
Michael Higgins 8b3621fa92 Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-18 10:04:43 -04:00
abc2cb 6690b8535d Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-18 17:03:16 +03:00
abc2cb d88d4a228d Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-18 17:03:15 +03:00
abc2cb 710c26af41 Merge remote-tracking branch 'upstream/main' into codex/github-smart-filters-usability-20261011
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-18 17:03:06 +03:00
abc2cb 66f91c3e25 Merge remote-tracking branch 'upstream/main' into feat/layers-panel-photoshop-options
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-18 17:03:06 +03:00
Higgs32584andClaude Opus 5.5 c8f7cfaae6 Artboards: paint pasteboard pieces as quads so a rotated view keeps the holes (#5712)
pasteboard_rects returned each piece's screen bounding box, which in a rotated
view covers the loose layers (and boards) it was cut around. pasteboard_quads
returns each piece's four screen corners and the canvas paints them as meshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:45:11 -04:00
Higgs32584andClaude Opus 5.5 e8848e8ba2 Auto-Blend Layers: turn the Background into a layer before masking it
Refs storytold/PhotoCraft#5745, split out of #5816.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:32:05 -04:00
Higgs32584andClaude Opus 5.5 17c9cda345 Artboards: the Background and full-canvas layers keep the pasteboard (#5712)
Review on #6410: any visible layer covering the whole canvas (the default
white Background, or a fill layer whose bounds are unbounded) removed the
pasteboard entirely. Those are backdrop, not loose work: pasteboard_rects
now skips the document's Background and any layer whose composite bounds
are unbounded or contain the canvas. The test helper is back to the
default white document, and full_canvas_layers_keep_the_pasteboard covers
it. Merging main also drops the rustfmt-only change to
smart_filter_authorization.rs from the PR diff.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:19:39 -04:00
Higgs32584 f73df05a84 Merge remote-tracking branch 'upstream/main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-18 08:24:46 -04:00
wwewtech c6e4d8a945 test(engine): make the define-brush mask allocation fail on its own, fail-after-one (#212) 2026-10-18 16:08:40 +04:00
Matthias Schlich 3192457c02 perf(smart-filters): find the object's bounds once per render
Render filters on a smart object asked for the placed stage each time. When the stage cache had
evicted it, every one of them placed (and decoded) the source again: four Lens Flares, four
decodes. The bounds are now found once and reused (CodeRabbit review on #6160).
2026-10-18 14:02:54 +02:00
Khawar Zamman WaniandClaude Opus 5.5 20385b7043 Test: a refused restore_layer_selection changes no selection or revision
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:52:34 +05:30
MarekSpodBiedry ff5b1228c6 Tab in Full Screen Mode shows the menu bar and panels; leaving Full Screen restores them as before (#6760) 2026-10-18 11:31:53 +02:00
nrq6993-a11yandCursor 59bb90ad1b Fix rustfmt on Color dock match arm trailing comma.
Ubuntu CI runs cargo fmt --check; block arms must not use trailing commas.

Co-authored-by: Cursor <[email protected]>
2026-10-18 12:30:48 +03:00
nrq6993-a11yandCursor 416e546562 Format stroke_constraint call in lasso_ui for rustfmt.
Co-authored-by: Cursor <[email protected]>
2026-10-18 12:17:33 +03:00
Matthias Schlich 690a73b595 Merge origin/main into feat/psd-render-smart-filters 2026-10-18 11:14:58 +02:00
Elvis33LE d1ee15a189 ci: retrigger (portal_save timed out on ubuntu; 9193/9194 passed, branch is gpu-test-only) 2026-10-18 11:04:18 +02:00
Khawar Zamman Wani 087e540c6b Merge origin/main into fix/5571-group-layers-collapsed 2026-10-18 14:27:54 +05:30
Khawar Zamman Wani 289ad2bb3e Merge origin/main into fix/pen-path-visible-while-panning-6592 2026-10-18 14:27:54 +05:30
Khawar Zamman Wani 6ab5b734e1 Merge origin/main into fix/6483-type-commit-on-layer-change 2026-10-18 14:27:54 +05:30
nrq6993-a11yandCursor 89522c5859 Fix panel-toggle shortcut test after modifier-aware capture (#7190).
Press plain F5 in the F5-reassignment scenario so capture does not inherit Shift from unrelated key events; satisfy clippy on capture_key_press.

Co-authored-by: Cursor <[email protected]>
2026-10-18 11:53:23 +03:00
nrq6993-a11yandCursor 214b1a0710 Color panel: add RGB slider mode for accessibility (#7172)
Users can switch the Color panel from HSB to RGB sliders under the field, with control-channel support and tests.

Co-authored-by: Cursor <[email protected]>
2026-10-18 11:25:02 +03:00
nrq6993-a11yandCursor a2372a9ce2 Fix rustfmt on viewport_icon for CI format check.
Co-authored-by: Cursor <[email protected]>
2026-10-18 11:08:41 +03:00
Elvis33LE 459a25bd95 Revert "perf(gpu): batch resident overlap copies"
This reverts commit f57bb1f3b1.
2026-10-18 09:17:34 +02:00
nrq6993-a11yandCursor ab668a1dff Freeform Pen: constrain freehand drags with Shift (#7171)
Holding Shift while drawing a freeform path segment keeps points on a
straight 0°/45°/90° line, reusing the painting-tool stroke constraint.

Co-authored-by: Cursor <[email protected]>
2026-10-18 10:05:32 +03:00
nrq6993-a11yandCursor fc298ebf12 fix(ui): record Shift in Keyboard Shortcuts capture (storytold/photocraft#7190)
Track modifier keys across capture frames and walk events so ⇧/⌃/⌥ count even when egui reports no modifier bits on them.

Co-authored-by: Cursor <[email protected]>
2026-10-18 09:32:53 +03:00
nrq6993-a11yandCursor 4b88e94f91 macOS: keep bundled Dock icon after eframe loads (storytold/photocraft#7253)
Skipping with_icon on .app bundles let eframe substitute its default glyph once
the window opened; pass IconData::default() so the bundle .icns stays in the Dock.

Co-authored-by: Cursor <[email protected]>
2026-10-18 09:04:38 +03:00
nrq6993-a11yandCursor 33398aeb1c fix(ui): allow collapsing brush folders while searching (storytold/photocraft#7257)
Search no longer forces groups and nested folders open; collapsed state is honored during filter.

Co-authored-by: Cursor <[email protected]>
2026-10-18 08:44:16 +03:00
Vikram Bhaskaran cf2d819fad Merge origin/main into fix/5258-psd-open 2026-10-18 11:54:46 +11:00
Khawar Zamman WaniandClaude Opus 5.5 f921d9af81 test(engine): keep the range test's group expanded
Group Layers now creates the group collapsed (#5571), so
shift_range_follows_visible_rows_on_both_sides_of_an_expanded_group expands it first, as its name
says. The line is from #5605 by @nrq6993-a11y.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:25:40 +05:30
Elvis33LE 7414914290 test(gpu): tile growth rewrites must beat the kept overlap copy
Grows a raster layer one tile per render and rewrites everything with
per-step noise, on the whole-texture and the paged compositor, comparing
each render against the CPU oracle. Fails at 254/255 when keep_overlap
does not route the resident through encoder-ordered writes (verified by
temporarily dropping new.ordered = true).
2026-10-17 17:53:39 +02:00
Khawar Zamman WaniandClaude Opus 5.5 9e2aa98177 Merge origin/main into fix/pen-path-visible-while-panning-6592
Picks up the build fixes on main (#6906, #7053).

Keep main's crates/engine/src/presets/mod.rs: #6906 restores export_presets there, so
this branch's copy of the same line would set the field twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:18:38 +05:30
Khawar Zamman WaniandClaude Opus 5.5 4bfaa89723 Merge origin/main into fix/5571-group-layers-collapsed
Picks up the build fixes on main (#6906, #7053).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:18:38 +05:30
Khawar Zamman WaniandClaude Opus 5.5 58d4620a8d Merge origin/main into fix/6483-type-commit-on-layer-change
Take main's version of crates/algo/src/features.rs: #7053 gates the same test helpers this
branch gated in 6bda896a.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:08:47 +05:30
Higgs32584andClaude Opus 5.5 eab7603ead background_redraw test: check the opacity change starts a redraw, not the perf label after one frame
The label is only recorded when the redraw finishes, so after a single step it still
read "kept" on slower adapters (FredApps' run on Linux's software adapter). Assert that a
redraw is running instead, and that it finished as "background".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:17:37 -04:00
Higgs32584 f1820242e1 Merge remote-tracking branch 'origin/main' into pr-6908 2026-10-17 11:17:31 -04:00
Higgs32584andClaude Opus 5.5 9f74db6980 Merge origin/main into fix/2603-toolbar-click-without-hover (pick up the #6906 build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:17:19 -04:00
Higgs32584andClaude Opus 5.5 3d53024a99 GPU canvas: test layer-page admission under a zero budget (#6563)
A paged render with a zero resident budget evicts the previous cell's pages as each
new page is created, stays on the GPU and matches the CPU compositor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:16:45 -04:00
Higgs32584andClaude Opus 5.5 d35c5032ed Merge main (with the #6906 build fix) into fix/6865-selection-not-dirty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:12:10 -04:00
Higgs32584andClaude Opus 5.5 fcea662989 style: sort touch_twins module per rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:07:30 -04:00
abc2cbandClaude Opus 5.5 2430664dd3 Smart Filters: note under Mode when Dissolve is selected
Dissolve in Smart Filter blending follows the inherited color-blending path
without per-pixel coverage sampling (docs/smart-filters.md). Show a short faint
note under the Mode control in Blending Options when Dissolve is chosen, so the
limitation is visible where the mode is picked. No behaviour change.

The note is added to all 20 catalogs. The test renders the dialog body for
every layer blend mode and checks that only Dissolve paints the note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:20:39 +03:00
abc2cbandClaude Opus 5.5 d685aa1484 Fill layers: pin the preference-off precedence for selection and explicit path
With "Use Default Masks on Fill Layers" off, only the default reveal-all mask
goes away. Cover that contract directly for Solid Color, Gradient and Pattern
Fill: no selection and no path gives no mask; an active selection still
becomes the raster layer mask (revealing inside, hiding outside); an explicit
vector path still becomes the vector mask, with or without a selection, and no
raster mask is added next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:19:18 +03:00
Khawar Zamman WaniandClaude Opus 5.5 87e8e5977c fix(engine): restore export_presets in load_presets_json (#5344 x #5616)
main stopped compiling when #5616 rebuilt Persisted field by field without
the export_presets field #5344 added. Same one-line fix as #6906, so it
merges cleanly whichever lands first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:19:16 +05:30
Khawar Zamman Wani fadeb802ef Merge remote-tracking branch 'origin/main' into fix/5571-group-layers-collapsed 2026-10-17 19:13:45 +05:30
Khawar Zamman WaniandClaude Opus 5.5 6bda896ad0 test(algo): gate the Linux-only hash test's helpers to Linux x86_64
`blocks` and `fnv` are only used by the harris/describe hash test, which runs
on Linux x86_64 only, so on Windows they were dead code and `-D warnings`
failed the build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:08:27 +05:30
abc2cb 193e86a66f Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-17 16:25:38 +03:00
abc2cb a6b5208aad Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-17 16:25:38 +03:00
abc2cbandClaude Opus 5.5 e727c3dcf8 Layers panel reorder preview: share move_to's structural move, clipping groups included
The hover preview (preview_move) re-implemented the layer removal and
insertion but skipped the rest of move_to: the clip-group rules
(clip_group::Before/settle), the Background copy rules (unlocked copy)
and the Background-stays-at-the-bottom check. So the preview showed
different clipping than the drop commits, e.g. B C1* C2* U with U dropped
above C1 committed B C1* U* C2* but previewed U unclipped, and moving a
clipping base away left its layers clipped in the preview.

Factor the document-only part of move_to into apply_move and call it from
both, so they cannot drift. A drop move_to would refuse (Background off
the bottom, groups nested too deep) now has no preview either.

Tests compare preview and committed documents (stack order, clip flags and
the flattened composite) for clip-group drops, a base moved away or up
into its own group, option-drag copies, a Background copy, and refused
Background drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:37 +03:00
abc2cbandClaude Opus 5.5 2197040799 Filter preview: main's view-capped factor follows the whole smart filter stack
The merge with main kept both factor lines in ensure_filter_preview, and the
second (main's filter_preview_factor, #6351) shadowed smart_preview_factor, so
a smart filter edit was sized by the edited command alone again. The shared
filter_preview_factor now starts from smart_preview_factor, which falls back to
preview_factor for ordinary filters, and caps that by the view as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:36 +03:00
Matthias Schlich 875ec69fce fix(filters): Clouds size their pattern by the canvas, not the selection
Since Clouds became a filter, its cloud size came from the filter's reference bounds, which are
the selection's when there is one: clouds in a selection were finer than the canvas's. Before,
they were sized by the document. Clouds now take the canvas as their bounds; the selection only
masks them, as before (review on #6160).
2026-10-17 12:28:10 +02:00
Matthias Schlich c6fff8a3a8 Merge origin/main (up to a1e19a0b) into feat/psd-render-smart-filters
# Conflicts:
#	crates/engine/src/smart_cmds.rs
2026-10-17 12:10:28 +02:00
abc2cb 538d9e0ece Merge branch 'main' into codex/github-smart-filters-usability-20261011 2026-10-17 12:13:35 +03:00
Khawar Zamman WaniandClaude Sonnet 5.5 44406fa17e Merge origin/main into fix/6483-type-commit-on-layer-change
Keeps both the caret-blink reset from main and the document-aware TextEdit.
Also adds the missing export_presets entry in load_presets_json, which broke
the engine build on main.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:58:28 +05:30
Khawar Zamman WaniandClaude Sonnet 5.5 bdf8d06926 Merge origin/main into fix/pen-path-visible-while-panning-6592
Also restores the build on main: load_presets_json lacked the new export_presets field.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:46:45 +05:30
Khawar Zamman Wani a2809d0040 Merge commit '13225791' into fix/5571-group-layers-collapsed 2026-10-17 09:37:42 +05:30
Higgs32584andClaude Opus 5.5 e8834e8d93 Selections don't mark a document modified (#6865)
Making, changing or dropping a selection (marquee, lasso, Magic Wand,
Quick / Object Selection, Select menu) is still a history step, but it
no longer dirties a clean document, nor do its undo, redo or a History
panel jump over selection-only states. A step counts as selection-only
when the documents differ in nothing but `selection`
(`layer_multi_cmds::selection_only_change`: pixels by tile pointer,
every other field structurally, exhaustive so new fields must be
classified).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:06:18 -04:00
Higgs32584andClaude Opus 5.5 1be48a8360 fix(web): one press per tap, so menus open again on touch (#2095)
In the browser eframe 0.36 turns one finger or pen tap into two primary
presses and two releases: one pair from Pointer Events (pointerdown runs
a frame of its own) and one from Touch Events (touchstart/touchend). The
menu bar titles and press menus toggle on the press, so the twin press
closed the menu the first had just opened.

The web shell's raw_input_hook now drops a primary press that arrives
while the button is already held (pressed within 0.5 s) and a release
while it is already up. A press held longer still passes, so a lost
release can't lock the pointer down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:38:29 -04:00
Michael Higgins cd9ec5c03e Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-16 22:24:35 -04:00
Michael Higgins 476a0fc3ef Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-16 22:20:24 -04:00
Michael Higgins b9dda08d01 Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-16 22:18:54 -04:00
Michael Higgins 81a4342b18 Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-16 22:18:47 -04:00
Higgs32584andClaude Opus 5.5 85acfeee65 Background redraw: keep the last finished composites, so a solo's restore uploads instead of recompositing (part of #6528)
Over the GPU memory budget a large visibility change is redrawn by the
CPU compositor in blocks, and nothing kept the result: Alt-click on an
eye and again recomposited every layer (4 s on a 50 MP PSB, ~100 s on a
multi-GB one). The last two finished redraws are now kept, keyed by the
whole document they drew (compared with ==, as the GPU prefix cache
does), the display texture key, the texture format and the block grid;
a redraw of an equal state uploads those blocks. Bounded by what Memory
Usage leaves beside the document and its History, dropped with the
document.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:14:47 -04:00
Higgs32584andClaude Opus 5.5 3f89289b84 test(ui): a toolbar click with no prior hover picks the tool (part of #2603)
The tool buttons are plain Sense::click widgets and need no hover first.
The offset the report describes is #2246, fixed by #2401 in 0.7.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:13:08 -04:00
Michael Higgins 7072965705 Remove duplicate Cicp import from lib.rs 2026-10-16 20:31:17 -04:00
Michael Higgins d356513052 Merge branch 'storytold:main' into fix/6752-heic-hlg 2026-10-16 20:27:09 -04:00
Higgs32584andClaude Opus 5.5 0769fa22ff Actions: map Select Subject, Select and Mask, Color Range, clipping masks, layer masks and colours from .atn (part of #6745)
autoCutout -> select.subject, smartBrushWorkspace -> select.refineEdge,
colorRange presets -> select.colorRange, groupEvent/ungroup -> create/release
clipping mask, make of a layer mask -> layer.layerMask.*, set of the
foreground/background colour -> tools.setColors. Selective Color's yellows
range is Photoshop's Ylws (legacy Yllw still reads). Steps whose params
can't be translated stay unsupported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:55:09 -04:00
Higgs32584andClaude Opus 5.5 3e373ddef8 GPU canvas: count pages evicted to admit a layer texture in render stats (#6563)
Admission now evicts before creating a page, so the post-cell evict found
nothing over budget and Stats::evicted stayed 0 (pages_are_evicted_under_the_budget).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:43:00 -04:00
Higgs32584andClaude Opus 5.5 41bc07d8f5 HEIF: read the nclx colour description; HLG/PQ HEICs open as linear BT.2020 32-bit (#6752)
The colr nclx primaries and transfer were dropped, so a 10-bit BT.2020 HLG
HEIC opened as if its samples were sRGB. photocraft-heif now returns the
nclx (inherited from grid tiles like the ICC profile), codecs decodes PQ and
HLG to linear light in 32-bit float (BT.2100 inverse OETF + OOTF at 1000
cd/m2, 203 cd/m2 diffuse white = 1.0), and io tags the document with the
matching profile from the new photocraft_cms::builtin::cicp_rgb. BT.709/sRGB
SDR files are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:24:47 -04:00
Higgs32584andClaude Opus 5.5 257f0a0d31 GPU canvas: a layer that doesn't fit GPU memory evicts and retries instead of breaking the GPU (#6563)
Compositor::sync created each pc_compose_layer page texture with a bare
create_texture: nothing checked the new bytes against the resident budget
(eviction ran only after each cell) and an Out of Memory error went
uncaptured, invalidating every later texture in the pass.

Now least recently used pages are evicted to admit the new page's bytes
before creating it; creation runs in error scopes (first_error) on native,
and on Out of Memory every page the current cell doesn't use is dropped and
creation is retried once. If it still fails the refresh returns
Unsupported(OVER_BUDGET) so that refresh draws on the CPU and the next one
tries the GPU again; the captured error no longer reaches the health
callbacks. The LRU admission logic is a pure function with a unit test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:34:59 -04:00
Higgs32584andClaude Opus 5.5 606b2dadba Actions: a Deselect step with nothing selected plays on instead of stopping (#6744)
Photoshop treats Deselect with no selection as a silent no-op, so imported
.atn actions that start with Deselect stopped at step 1 here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:26:04 -04:00
Khawar Zamman WaniandClaude Opus 5.5 ff411fa9e1 test(ui-egui): the Pen-while-panning test expects Windows' fist bitmap
On Windows the fist is a cursor bitmap with a `Crosshair` fallback icon, not the
`None` that `tool_cursor::hand` returns, so comparing the frame's icon to that
return value failed there. Check the icon and bitmap each platform gets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:20:47 +05:30
abc2cb 80dcc22a70 Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012
# Conflicts:
#	crates/engine/src/jobs.rs
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
2026-10-16 16:42:09 +03:00
abc2cb aba7cfe85e Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012
# Conflicts:
#	crates/engine/src/jobs.rs
2026-10-16 16:42:05 +03:00
abc2cbandClaude Opus 5.5 b0350c64c1 Session::start: drop the checks main moved into admit
The merge with main kept the old precondition, job conflict and hidden layer
checks next to main's `self.admit(...)` (#5684), which runs the same checks,
so every started command was checked twice. The New Effects disclosure block
now follows admit, as it followed those checks before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:41:04 +03:00
Khawar Zamman WaniandClaude Opus 5.5 9b5cb29395 Canvas overlays stay visible while panning (#6592)
Every canvas overlay (the Pen path being drawn, paths, guides, grid,
transform box, crop/slice/gradient overlays) was skipped for as long as
a Hand drag lasted (the tool, Space or the middle button), so the Pen
path vanished while panning. Overlays now always draw; only the tool
cursors yield to the fist (#2196). The transform-only workaround from
#3610 is folded into the general fix.

Test: the_pen_path_being_drawn_stays_visible_while_space_pans (fails
without the fix); transform_preview_stays_visible_while_panning stays
green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:46:16 +05:30
abc2cb a3d72f6f6a Merge branch 'main' into feat/layers-panel-photoshop-options 2026-10-16 14:14:04 +03:00
abc2cb cdfa71557d Merge branch 'main' into codex/layers-subrow-scaling-20261012 2026-10-16 14:13:23 +03:00
abc2cb f0543a19ed Merge branch 'main' into codex/fill-mask-preview-20261012 2026-10-16 14:13:04 +03:00
abc2cb 0ab262a0e3 Merge branch 'main' into codex/github-smart-filters-usability-20261011 2026-10-16 12:20:31 +03:00
Khawar Zamman WaniandClaude Opus 5.5 444ee06e1a fix(type): keep the layer just selected when ending a Type Mask (#6483 review)
Ending an empty Type Mask undoes its session, and undo restores the layer selection from
before it, so selecting another layer (or Deselect Layers) during the session was reversed.
`end_if_left` now restores the selection the user made when the commit moved it, through a new
`Session::restore_layer_selection` (drops gone layers, refuses a gone active layer, keeps a clean
document clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 13:23:42 +05:30
Khawar Zamman WaniandClaude Opus 5.5 3c91200d88 fix(type): commit a type edit when another layer or document becomes active (#6483)
Clicking another layer while the caret was in a type layer left the edit open, so single-key
tool shortcuts were typed instead. #5612 covered Layers-panel row clicks; every other route
(layer menus, the canvas layer picker, Deselect Layers, automation, a document tab, New/Open)
still left the session open.

A frame guard (`type_tool::end_if_left`) now commits once the edit's layer isn't the active
layer or its document isn't active. `TextEdit` records its document: layer ids repeat across
documents (one .pcraft opened twice), and typing after a tab switch used to edit the other
document's same-id layer. Commits run in the edit's own document; a closed document's edit
is dropped without running anything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 13:23:42 +05:30
denstu473 ac8e60ff2e fix(ui): preserve shape stroke settings at zero width
Keep the selected shape's stroke when width reaches zero so subsequent
width edits retain its paint and geometry settings in both UI controls.
Preserve explicit No Color removal and gesture-scoped undo behavior.

Add real-widget regressions for style retention, native persistence,
rendering, undo/redo, and deliberate stroke activation.

Fixes #6191.
2026-10-16 07:11:27 +02:00
nrq6993-a11y 801e229d76 style(text): rustfmt antialias fringe_mass chain for #5902 (PR #5907) 2026-10-16 06:47:56 +03:00
Higgs32584andClaude Opus 5.5 a80ece00aa Merge origin/main; cargo fmt (new_doc_ui.rs, smart_filter_authorization.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:01:51 -04:00
Higgs32584 5d9093bf0c Merge remote-tracking branch 'origin/main' into HEAD 2026-10-15 22:49:34 -04:00
Higgs32584andClaude Opus 5.5 b6be26a490 rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:28:54 -04:00
Higgs32584 8cedb81a63 Merge remote-tracking branch 'origin/main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-15 22:28:09 -04:00
Higgs32584andClaude Opus 5.5 73459a662c Artboards: layers outside the boards stay visible over the pasteboard (#5712)
Adding an artboard next to earlier work made that work vanish: the canvas
painted the opaque pasteboard over the whole image outside the boards,
hiding layers that are not in an artboard. Photoshop shows them, so the
pasteboard now leaves out where visible loose layers draw (their
composite bounds).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:44:21 -04:00
Higgs32584andClaude Opus 5.5 eee57c02ce Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:38:26 -04:00
Higgs32584andClaude Opus 5.5 c1c44507d1 Automation: file.new's profile path is an ambient read, gated like Assign Profile's (#321)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:40:18 -04:00
Higgs32584andClaude Opus 5.5 5f9c7d307e New Document: Color Profile, the working space or another profile of the mode (#321)
Photoshop's New Document has Advanced Options › Color Profile; PhotoCraft's always opened in the
working space, so a document in another space had to be made and then assigned a profile.
`file.new` takes `profile` (working, a built-in id or an .icc path), checked against the colour
mode before the document opens, and the New Document dialog lists the working space (by its
profile) and the built-in and installed profiles of the mode's colour space. Changing the mode
drops a profile of the old space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:25:28 -04:00
nrq6993-a11yandCursor 81b6cdf0d2 text: apply anti-alias mode shaping when rasterizing type
Photoshop's Sharp/Crisp/Strong/Smooth settings only changed metadata; shape edge coverage per mode (fixes storytold/photocraft#5902).

Co-authored-by: Cursor <[email protected]>
2026-10-15 23:45:10 +03:00
Elvis33LE f57bb1f3b1 perf(gpu): batch resident overlap copies 2026-10-15 19:30:51 +02:00
Matthias Schlich b228c1c0c2 feat(psd): map Clouds, Difference Clouds, Fibers and Lens Flare smart filters
PSD smart filters Clds, DfrC, Fbrs and LnsF open as editable PhotoCraft filters instead of
kept-verbatim entries, and are written back like Photoshop: seeds, the item's foreground and
background colours, brightness, centre and lens (Zm/Nkn/Nkn1/PnVs = 50-300mm Zoom, 35mm,
105mm and Movie Prime), with Photoshop's descriptor names. Checked against items saved by
Photoshop 27.
2026-10-15 16:05:34 +02:00
Matthias Schlich 50a011ae9e feat(filters): Clouds and Difference Clouds as smart filters; render filters work within a smart object
Clouds and Difference Clouds ran outside the filter pipeline (map_pixels with the session's
colours), so on a smart object they failed instead of adding a smart filter. They are now a
regular render filter (FilterParams::Clouds, same noise as before) that records its colours,
so a smart filter re-renders the same clouds after the colours change.

Render filters on a smart object (Clouds, Difference Clouds, Fibers, Lens Flare) now use the
placed content's bounds, as Photoshop does: a flare's centre is a fraction of the object, not
of the canvas.
2026-10-15 16:05:34 +02:00
Michael Higgins f571673fc2 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-15 09:34:15 -04:00
Michael Higgins 8251a722b9 Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-15 09:34:00 -04:00
Vikram Bhaskaran 6b7f08fcd6 Merge remote-tracking branch 'origin/main' into fix/5258-psd-open
# Conflicts:
#	crates/io/src/lib.rs
2026-10-15 22:56:14 +11:00
Vikram Bhaskaran 26cdfae307 IO: route declared Photoshop extensions through is_psd_input.
The helpers-only use made cargo clippy -p photocraft-io --all-targets
flag is_psd_input as never used; import_stages now calls it for the
extension branch, keeping the magic-first order that lets an Affinity
file renamed .psd stay with the Affinity loader.
2026-10-15 22:55:05 +11:00
abc2cbandClaude Opus 5.5 eae5eecab5 Smart filter preview: don't scale the placement and mask twice
Main's proxy_document now scales a smart object's placement, perspective and
filter mask with the proxy (#5779). The smart-edit preview still did it again,
so a reduced preview drew the object and its mask at a quarter size
(reduced_preview_scales_placement_stack_and_mask_without_adding_filters).
Only the filter lengths and the mask's feather are left to scale here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:59:13 +03:00
abc2cb 0a353ae85e Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-15 11:20:55 +03:00
abc2cb e47b4ddc1c Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-15 11:20:54 +03:00
abc2cb c68541dc26 Merge remote-tracking branch 'upstream/main' into feat/layers-panel-photoshop-options
# Conflicts:
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
2026-10-15 11:20:36 +03:00
abc2cb 7635c61f89 Merge remote-tracking branch 'upstream/main' into codex/github-smart-filters-usability-20261011
# Conflicts:
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/smart_ui.rs
2026-10-15 11:20:35 +03:00
varanus-salvatorandClaude Opus 5.5 3227bf8366 Stroke: keep Overprint (overprint)
Photoshop stores Stroke › Overprint as `overprint` in the FrFX
descriptor. StrokeFx had no field for it, so an edited stroke was
written back without it and a stroke with Overprint on lost it.

Add `StrokeFx::overprint` (serde default, so older .pcraft files load),
read and write `overprint`, accept it in layer.layerStyle.stroke and show
the Overprint checkbox in the Layer Style dialog, as Photoshop does.
Rendering is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:55:18 +07:00
varanus-salvatorandClaude Opus 5.5 92aa5047cd Bevel & Emboss: keep the Gloss Contour's Anti-aliased (antialiasGloss)
Photoshop stores Bevel & Emboss › Gloss Contour › Anti-aliased as
`antialiasGloss` in the effect descriptor. The document model had no
field for it, so import dropped it and export regenerated the bevel
without it: once a bevel was edited (the Layer Style dialog re-applies
all its settings), a saved PSD reopened in Photoshop with the gloss
contour aliased.

Add `Bevel::gloss_anti_alias` (serde default, so older .pcraft files
load), read and write `antialiasGloss`, accept `glossAntiAlias` in
layer.layerStyle.bevelEmboss and show the Anti-aliased checkbox next to
the Gloss Contour in the dialog, as Photoshop does. Rendering is
unchanged: contour anti-aliasing is not rendered for any effect yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:31:59 +07:00
abc2cbandClaude Opus 5.5 2e55f5eaa1 Merge Layers Panel part 1 (es.tsv duplicates dropped) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:36:04 +03:00
abc2cbandClaude Opus 5.5 79a8a795e3 Merge Layers Panel part 1 (es.tsv duplicates dropped) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:36:03 +03:00
abc2cbandClaude Opus 5.5 e3f95672df i18n(es): drop this PR's copies of rows main's catalog now has
main's Spanish catalog (#4306) added New Layer…, Panel Options… and Lock
Layers, so bundled_catalogs_are_consistent failed on the duplicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:35:59 +03:00
Higgs32584andClaude Opus 5.5 86d1630de9 Tests: the Move readout drag starts on the layer (#5727)
With Auto-Select (on by default) a press on an empty spot starts the layer
marquee, not a move; the test pressed at (32, 32), just outside the 16..32
square. It now presses inside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:35:33 -04:00
Higgs32584andClaude Opus 5.5 1f745a9d45 Tests: Displace's map picker is asked when the frame polls it (#5806)
ask_file only queues the request; the file dialog service is called from
poll_file_dialog, so the request count is checked after the poll that shows
the picker and acts on its answer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:35:31 -04:00
abc2cbandClaude Opus 5.5 7d6712603d Merge Layers Panel part 1 (main fd28da56, es.tsv duplicates dropped) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:53 +03:00
abc2cbandClaude Opus 5.5 a5f8463e9b Merge Layers Panel part 1 (main fd28da56, es.tsv duplicates dropped) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:52 +03:00
abc2cbandClaude Opus 5.5 b90ddb338a Merge upstream/main (fd28da56) into Smart Filters editors
server.rs takes main's ui_set layerFilter description (#5776), which
replaces the earlier stopgap here. adjust_dialog.rs: a Smart Filter's
Curves/Levels editor reads its smart object's histogram, alongside main's
isolated mask or channel curve target (#5335).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:33 +03:00
abc2cbandClaude Opus 5.5 99cfcf40a7 Merge upstream/main (fd28da56) into Layers Panel options
server.rs takes main's ui_set layerFilter description (#5776), which
replaces the earlier stopgap here. es.tsv: main's Spanish catalog (#4306)
now has New Layer…, Panel Options… and Lock Layers, so this PR's copies
of those rows are dropped (bundled_catalogs_are_consistent).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:33 +03:00
Higgs32584 cae5a506c0 Merge main into fix/5727-move-tool-offset-readout 2026-10-14 23:31:29 -04:00
Higgs32584 48e12118d1 Merge main into fix/5806-displace-asks-for-map 2026-10-14 23:30:00 -04:00
Khawar Zamman WaniandClaude Opus 5.5 8df66a71ca Layers filter tests: expand the fixture's group (#5571)
The fixture builds "Sheets" with layer.groupLayers, which now creates the
group collapsed. The unfiltered baseline lists only expanded groups'
contents (Photoshop), so open the group as the other group tests do. The
filtered checks already look inside collapsed groups (display_rows gets
show_all while a filter is active).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:50:32 +05:30
Khawar Zamman Wani e8f6b89c9c Merge remote-tracking branch 'origin/main' into fix/5571-group-layers-collapsed 2026-10-15 08:36:06 +05:30
Sir Starch 6dffa480ac BMP export: Indexed Color documents save as palette BMPs
Saving an Indexed Color document as BMP went through the ordinary RGB
path and wrote a 24-bit file, so the colour table was lost on reopen
(the PNG exporter already had a palette branch).

export_mode_specific now sends Indexed Color to BMP as well as PNG,
through a new encode_bmp_indexed that writes an 8-bit palette BMP with
the document's table. BMP has no transparency, so a transparent index
is written as its palette colour and the export says so.

bmp_palette returned the decoder's 256-entry padding, so a 3-colour BMP
reopened with 253 extra black entries. It now trims the table to the
colours the header declares.
2026-10-15 04:08:28 +02:00
Michael Higgins 5b026c1979 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-14 20:16:07 -04:00
Michael Higgins 17d0a55baf Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-14 20:07:58 -04:00
Higgs32584andClaude Opus 5.5 b54a4a8f86 Displace: OK asks for the displacement map, as in Photoshop (#5806)
Filter > Distort > Displace took its map only from an open document or a
path typed into a text field; OK without either ran with no map, and no file
picker ever appeared. Confirming the dialog with no map now opens the file
picker and runs Displace with the chosen file (smart filters included);
Cancel applies nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:54:11 -04:00
abc2cb ad236be126 Merge Layers Panel part 1 (ui_set layerFilter description fix) into codex/fill-mask-preview-20261012 2026-10-14 22:34:53 +00:00
abc2cb 49aba9f2bd Merge Layers Panel part 1 (ui_set layerFilter description fix) into codex/layers-subrow-scaling-20261012 2026-10-14 22:34:52 +00:00
abc2cb 0e009c246f Name layerFilter in the ui_set tool description
main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not,
so ui_set_description_names_every_accepted_field fails on ubuntu.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT
(cherry picked from commit 1d1cb4e462)
2026-10-14 22:34:50 +00:00
abc2cbandClaude Sonnet 5.5 1d1cb4e462 Name layerFilter in the ui_set tool description
main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not,
so ui_set_description_names_every_accepted_field fails on ubuntu.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT
2026-10-14 22:34:47 +00:00
abc2cbandClaude Opus 5.5 4499458d32 Merge Layers Panel part 1 (main 8d840eab) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:09:07 +03:00
abc2cbandClaude Opus 5.5 5cc5a9ed13 Merge Layers Panel part 1 (main 8d840eab) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:09:03 +03:00
abc2cbandClaude Opus 5.5 a40b95f250 Merge upstream/main (8d840eab) into Layers Panel options
Main landed Clip Thumbnails to Layer Bounds (#4281, #4312), the same
setting as this PR's Panel Options › Thumbnail Contents, and the Layers
panel filter-type dropdown (#5048, #5268):

- Thumbnails use main's frame and cache (layer_thumb, thumb_image); this
  PR's own Layer Bounds region code is gone. The Panel Options dialog's
  Layer Bounds / Entire Document now shows and sets
  interface.clipThumbnailsToLayerBounds, which the panel menu's checkbox
  toggles too, so the two can't disagree; it is no longer saved with the
  panel options.
- Filter Options › Show hides main's new filter bar.
- Catalogs keep main's hu/sr/tr/vi rows and this PR's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:03:05 +03:00
abc2cbandClaude Opus 5.5 d16b4f7302 Merge upstream/main (8d840eab) into Smart Filters editors
Resolve smart_ui.rs: a Filter Gallery smart filter opens main's gallery
window (#3426) before this PR's editor routing; catalogs keep main's
hu/sr/tr/vi rows and this PR's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:58:05 +03:00
Higgs32584andClaude Opus 5.5 097848ddb5 Move tool: ΔX/ΔY readout beside the pointer while dragging (#5727)
Only Free Transform drew a readout of how far the layer moved (its
Hit::Inside offset), so a Move-tool drag inside the transform controls,
or without them, showed nothing. Photoshop shows ΔX/ΔY for a Move drag.

The drag preview now draws the offset the release will commit
(move_ui::drag_offset, whole pixels), for a layer drag and a drag of
selected pixels, once the pointer has moved. It goes through
draw_value_readout, so Preferences > Tools > Show Transformation Values
hides it like the other readouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:50:36 -04:00
Nigel Ihza Firdausy 1b142a4aa7 Merge current main and resolve reviewed PR conflicts 2026-10-14 21:42:34 +07:00
Nigel Ihza Firdausy b92f49a6cb Merge current main and retain reviewed PR changes 2026-10-14 21:34:33 +07:00
Khawar Zamman WaniandClaude Opus 5.5 70886c0d88 Layers: Group Layers creates the group collapsed (#5571)
Photoshop's Group Layers (Cmd/Ctrl+G) and Group from Layers create the new
group collapsed, so the Layers panel shows one row in place of the grouped
layers. layer.groupLayers and layer.new.groupFromLayers now do the same.
Layer::group still defaults to expanded, so New Group, PSD import and
artboards are unchanged.

Tests that walk group contents now open every group first with
layer.setExpanded {"all":true,"expanded":true}.

Closes #5571

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNoCkwuu6CgnxJAii7QExa
2026-10-14 19:57:02 +05:30
Vikram Bhaskaran f784275180 Open Affinity files renamed .psd with the Affinity loader.
Magic 8BPS still goes to the PSD reader. A .psd suffix without that signature is PSD only after Affinity (and other native) magic is ruled out, so a renamed Affinity preview does not fail as an invalid PSD.
2026-10-14 18:34:48 +11:00
Gert Pellin 1c7989f607 Merge remote-tracking branch 'upstream/main' into feat/4814-duotone-inks 2026-10-14 09:22:02 +02:00
Gert Pellin 2c5ed1c91d Layered TIFF corpus test: a flat Duotone is expected with its inks rendered
With the ink block read, an opened duotone has its real inks. A
single-layer one is saved flat, which writes the inks rendered as RGB
(flat.rs); the test compared that with the grey plate.

Refs #4814
2026-10-14 09:22:00 +02:00
Vikram Bhaskaran 478c986972 IO: open .psd/.psb/.psdt through the PSD reader, not codecs (#5258)
File › Open sent Photoshop extensions that lacked an 8BPS prefix (or
reached import by name) to photocraft-codecs, which reports
"unrecognized image format". Route them like other native formats, and
offer a Photoshop filter in the open dialog.
2026-10-14 18:04:46 +11:00
Abdul karim mia c8ff8e3860 Add Color Table dialog translations 2026-10-14 10:58:39 +05:30
Abdul Karim Mia fef5e042bd Support .act color tables end to end (swatches + Color Table dialog)
Closes #1127. Photoshop's .act (256 RGB triplets, optional count/
transparent trailer) now loads everywhere swatches do: a new
photocraft-psd act parser/writer (byte-stable round trip, hostile
lengths are errors), Format::Act in swatches.import/export with
length/extension sniffing, the Swatches panel menu, File > Open
(desktop + web filters, drag-and-drop) and Load/Save buttons in
Image > Mode > Color Table. Verified against a Photoshop-saved
table (256 colors, byte-identical re-export).
2026-10-14 10:35:30 +05:30
Gert Pellin 186dbfe4cd Merge branch 'feat/4814-psd-palette-modes' into feat/4814-duotone-inks 2026-10-13 21:47:44 +02:00
abc2cbandClaude Opus 5.5 08717e7bcf Merge Layers Panel part 1 (main 99e11388) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:39:27 +03:00
abc2cbandClaude Opus 5.5 8313dadcc5 Merge Layers Panel part 1 (main 99e11388) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:39:05 +03:00
abc2cbandClaude Opus 5.5 014207fa51 Merge upstream/main (99e11388) into Layers Panel options
Picks up main's rustfmt fix for rotate_view.rs and wheel_nav.rs, which
failed the Format step at 74cb2935.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:39:01 +03:00
Fabio Lissi 9e953aa897 Merge remote-tracking branch 'origin/main' into feat/atn-corpus 2026-10-13 13:00:18 -05:00
abc2cbandClaude Opus 5.5 dc0aba4be2 Merge upstream/main (99e11388) into Smart Filters editors
Resolve filter_dialog.rs: main's Blur Gallery pin scaling for proxy
previews (#3801) moves into scale_preview_params, which this PR uses for
every preview, so pins also scale when Blur Gallery runs as a Smart
Filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:33:41 +03:00
abc2cbandClaude Opus 5.5 aef99241eb Merge Layers Panel part 1 (main 74cb2935) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:05:14 +03:00
abc2cbandClaude Opus 5.5 8d9668bf87 Merge Layers Panel part 1 (main 74cb2935) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:05:14 +03:00
abc2cbandClaude Opus 5.5 82ea23e9a3 Merge upstream/main (74cb2935): keep main's hu/sr/tr rows and this PR's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:55:40 +03:00
abc2cbandClaude Opus 5.5 f43f9df2d7 Merge upstream/main (74cb2935): keep main's hu/sr/tr rows and this PR's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:55:39 +03:00
abc2cbandClaude Opus 5.5 dfb44a5e3e i18n(sr): translate Use Default Masks on Fill Layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:54:43 +03:00
abc2cbandClaude Opus 5.5 e86852ac83 Merge Layers Panel part 1 (main 523f1f06, Serbian rows) into Fill layer masks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:52:05 +03:00
abc2cbandClaude Opus 5.5 288f85453b Merge Layers Panel part 1 (Serbian rows) into sub-row scaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:52:02 +03:00
abc2cbandClaude Opus 5.5 12ec8fceea i18n(sr): translate this PR's strings in the new Serbian catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:51:53 +03:00
abc2cbandClaude Opus 5.5 6d37936db3 i18n(sr): translate this PR's strings in the new Serbian catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:51:50 +03:00
abc2cbandClaude Opus 5.5 a7ce975694 Merge Layers Panel part 1 (main 523f1f06) into sub-row scaling
Resolve smart_ui.rs: main's filter row selection, drag and reorder
(#4371, #4764, #4897) keep working on rows sized by this PR (compact
sub-rows, the Smart Filters header and its mask follow the thumbnail size);
this PR's scaling test sits next to main's inline unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:50:26 +03:00
abc2cbandClaude Opus 5.5 7505ebdae3 Merge upstream/main (523f1f06) into Layers Panel options
Resolve the conflicts by keeping both sides: the live reorder preview next
to main's Content-Aware Move preview (canvas.rs), the Layers panel options
dialog next to main's rasterize prompt (lib.rs), the thumbnail size test
next to main's lock icon tests (layer_row_ui/tests.rs), and main's hu/tr/vi
rows plus this PR's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:47:43 +03:00
abc2cbandClaude Opus 5.5 641155b3bc Merge upstream/main (cdfec72f) into Smart Filters editors
Resolve the conflicts with main's smart filter row selection, drag and
reorder (#4371, #4897), channel-targeted filter previews and the new
hu/tr/vi rows:

- smart_ui.rs: filter rows keep this PR's layout (name and Blending
  Options icon as separate targets, indents) and take main's click to
  select, drag and drag-to-reorder; the name forwards them to the row.
  This PR's widget tests move to smart_ui/editor_tests.rs next to main's
  inline unit tests.
- filter_dialog.rs, canvas.rs: the smart filter preview scaling and
  smart_preview_factor run inside main's preview_document_for_target,
  with its channel target and source documents.
- i18n: main's rows plus this PR's; hu rows use informal address (#4334).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:39:11 +03:00
Nigel Ihza Firdausy 66ebd86873 Merge current main after toolbar flyout port 2026-10-13 22:58:35 +07:00
Nigel Ihza Firdausy 7fc8b5d8b4 fix(i18n): translate pending screen capture message in all catalogs 2026-10-13 22:57:40 +07:00
Nigel Ihza Firdausy b314f9c565 Merge upstream main and restore toolbar press-drag flyout selection
Port grouped-tool press-drag-release onto the current panels toolbar,
preserving pointer-outside dismissal and long-press behavior. Add
an egui interaction regression plus boundary cases for gesture pickup.
2026-10-13 22:57:39 +07:00
Fabio Lissi 74ad8ee5bc Pin a corpus of real Photoshop Actions sets and test the .atn parser and import on it (#219)
- corpus/atn: 16 sets from seven public repositories (MIT, BSD, Unlicense), fetched at pinned
  commits by cargo xtask corpus --atn, verified against xtask/atn-corpus.sha256, licences next to
  the files, rows in ATTRIBUTION.md and docs/development.md. photocraft-corpus takes only our own
  generated files, so third-party sets are pins here, like psd-tools and ag-psd.
- photocraft-psd corpus_atn: every set parses and rewrites byte for byte (floor 16 files).
  photocraft-engine corpus_atn: every set loads, saves and reloads to the same actions; the
  steps still kept as Photoshop steps are capped (517 of 5 296 measured; generative AI and
  scripts), and the first differing step is named on a mismatch.
- What the corpus found: localised panel names are platform legacy bytes (Mac Roman), now kept
  verbatim with AtnItem::label from the descriptor's Unicode class name; exporters emitted
  Photoshop defaults the importer had not read (now symmetric); setAdjustment carries its kind;
  and 300 more steps map: transform without a reference, selection nudges, moves to the bottom,
  View menu items, toolbox colours, clipping masks, ungroup, copy/paste styles, collapse groups,
  layer comps. New example atn_roundtrip shows the first differing byte.
2026-10-13 08:29:38 -05:00
Gert Pellin 772cf39c09 PSD Duotone: read and write the ink block (names, colours, curves)
The Duotone Color Mode Data is undocumented. From files Photoshop 2025
made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as
in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four
transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20,
..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain
and eleven overprint colours.

- Import interprets the inks, so an opened duotone shows them instead of
  its grey plate; the block is still kept raw and written back while the
  inks are unedited. A colour-book ink (not readable here) shows as black
  with a warning.
- Export writes a block for duotones made in PhotoCraft (RGB ink colours,
  curves on the 13-point grid or sampled at it, default dot gain,
  computed overprints), so every 8/16-bit Duotone is saved as Duotone.

Photoshop opens such files as Duotone, and a black monotone whose curve
is 0 up to 60 % density stays white exactly up to 60 % there.

Refs #4814
2026-10-13 12:19:37 +02:00
Sean Lavery 5af951d2d9 Merge current upstream main into TIFF type fix 2026-10-12 22:47:40 -07:00
Sean Lavery ec56c286dd Fix mixed-order TIFF type transforms and reject unsafe geometry 2026-10-12 22:39:20 -07:00
Nigel Ihza Firdausy 6c7b9d879d fix(screen-picker): allow cancelling pending captures and avoid stale answers 2026-10-13 11:57:35 +07:00
Nigel Ihza Firdausy 5d753525cc fix(screen-picker): allow cancelling pending captures and avoid stale answers 2026-10-13 11:57:30 +07:00
abc2cbandClaude Opus 5.5 3fbdc1a947 i18n: translate Use Default Masks on Fill Layers in hu, tr and vi
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:11:38 +03:00
abc2cb 224af59267 Merge Layers Panel part 1 (main + hu/tr/vi translations) into fill masks 2026-10-13 01:11:20 +03:00
abc2cb b6fece801c Merge Layers Panel part 1 (main + hu/tr/vi translations) into sub-row scaling 2026-10-13 01:11:18 +03:00
abc2cbandClaude Opus 5.5 8bc312ac59 i18n: translate the Smart Filter editor strings in hu, tr and vi
Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was
written, so every_tl_literal_is_translated fails on the merged tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:11:08 +03:00
abc2cbandClaude Opus 5.5 6bf45d05ac i18n: translate the Layers Panel strings in hu, tr and vi; drop Italian duplicates
Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was
written, so every_tl_literal_is_translated failed on the merged tree. Main
also translated New Layer…, Panel Options… and Lock Layers in Italian
(#3742); drop this branch's copies, which bundled_catalogs_are_consistent
rejects as duplicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:10:38 +03:00
abc2cb 2a54c3a573 Merge upstream main into Smart Filters editors 2026-10-13 01:10:05 +03:00
abc2cb 7fc98eb769 Merge upstream main into Layers Panel part 1 2026-10-13 00:58:53 +03:00
abc2cbandClaude Opus 5.5 9323037883 Keep Smart Filter and effect rows compact at every thumbnail size
Photoshop keeps the Effects, effect and Smart Filter rows at their compact
height and font at every Layers panel thumbnail size; only the layer row
grows, and the Smart Filters header grows just enough for its shared-mask
thumbnail, which follows the thumbnail size (hidden at None).

Previously every sub-row took the layer row height and a 13 pt font at Large,
so sub-rows outsized the layer name. Keep sub-rows at 22 px, 11.5 pt and a
12 px eye; scale only the Smart Filters header and its mask. Effect rows are
back to main's layout. The test checks both and can save renders with
PHOTOCRAFT_LAYERS_SNAPSHOTS=<dir>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:09:28 +03:00
abc2cb f3848ed6c8 Merge branch 'codex/github-layers-panel-maintenance-20261011' into codex/fill-mask-preview-20261012 2026-10-12 08:23:25 +03:00
abc2cb c9f986057c Merge branch 'codex/github-layers-panel-maintenance-20261011' into codex/layers-subrow-scaling-20261012 2026-10-12 08:23:25 +03:00
abc2cb cfe712c340 Apply panel copy-name setting to the new artboard duplication path 2026-10-12 08:22:30 +03:00
abc2cb e1b2d49973 Honor default fill masks consistently in creation and Pattern Fill preview 2026-10-12 08:19:25 +03:00
abc2cb 475a0745d8 Scale Smart Filter and effect rows with Layers thumbnails 2026-10-12 08:19:25 +03:00
abc2cb 44c52a013a Keep Layers Panel menu and options; extract sub-row scaling and fill masks 2026-10-12 08:17:55 +03:00
abc2cb a42e4f9a2d Merge main while preserving panel options and current engine behavior 2026-10-12 08:14:33 +03:00
abc2cb 3ce9b2e0b8 Merge main after removing auxiliary CI repairs 2026-10-12 08:14:32 +03:00
abc2cb 3ad6011bc5 Remove shared main repairs from Layers Panel feature scope 2026-10-12 08:10:48 +03:00
abc2cb a33b7929a0 Remove shared main repairs from Smart Filters feature scope 2026-10-12 08:10:47 +03:00
abc2cb b8a7c93b4f Merge current main and retain both sets of translations 2026-10-11 23:31:43 +03:00
abc2cb 6548e1c194 Merge current main and retain both sets of translations 2026-10-11 23:31:42 +03:00
abc2cb 28a3514a73 Deny ambient installed profile discovery in automation 2026-10-11 23:29:29 +03:00
abc2cb 69b1f2544d Deny ambient installed profile discovery in automation 2026-10-11 23:29:29 +03:00
abc2cb 56030984b3 Merge main, deduplicate layer labels and optimize browser command code 2026-10-11 23:00:40 +03:00
abc2cb 0364da6ae9 Merge main and optimize browser command code for size 2026-10-11 23:00:40 +03:00
abc2cb 8911a4ffbe test(engine): expect signed HDR outputs from destructive Levels 2026-10-11 22:26:22 +03:00
abc2cb 05af9b146b test(engine): expect signed HDR outputs from destructive Levels 2026-10-11 22:26:13 +03:00
abc2cb 849d064cc8 Merge current main into Layers Panel PR and align Pattern Fill previews 2026-10-11 22:13:58 +03:00
abc2cb 32b1ae2e5a Merge current main into Smart Filters PR and preserve both preview paths 2026-10-11 22:13:58 +03:00
abc2cb 338304e712 Merge upstream main into Layers Panel PR, preserving docking, layer moves and localized options 2026-10-11 10:54:25 +03:00
abc2cb 1515cafae4 fix(engine): retain Smart Filter coverage around blur edges 2026-10-11 10:31:23 +03:00
abc2cb f9f1d823cc Make Smart Filter editors distinct and preserve alpha at partial strength 2026-10-11 09:59:42 +03:00
abc2cb 135dc559a6 Merge upstream/main into Layers Panel feature (preserve new upstream changes)
Update to upstream 1e0f884; integrate UI/menu improvements and 17-language catalog updates while retaining Layers Panel functionality and screenshots outside the source branch.
2026-10-11 02:13:39 +03:00
abc2cb 6e0b3330c9 feat(ui): bring Photoshop-style Layers Panel options and scalable Smart Filters
Add a functional Layers flyout and Layers Panel Options dialog, configurable thumbnail sizes and bounds, mask/effect/copy preferences, and canvas reorder previews. Scale smart filter and layer effect rows with layer thumbnail size, and add regression coverage and translations.

Known limitation: Animation Options remains disabled because frame-property unification controls are not implemented.
2026-10-11 02:06:56 +03:00
Nigel Ihza Firdausy 8aebfd3ab3 Merge upstream main and port toolbar drag-release selection (#2301) 2026-10-11 05:26:33 +07:00
Nigel Ihza Firdausy f9795681b2 fix(toolbar): port drag-release flyout selection onto upstream toolbar 2026-10-11 05:26:15 +07:00
Nigel Ihza Firdausy b15b5a7587 feat(toolbar): drag from tool group into flyout to pick hidden tools (#2301) 2026-10-10 13:06:43 +07:00
301 changed files with 19087 additions and 1457 deletions

No files matched your search

+16 -14
View File
@@ -113,7 +113,7 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
@@ -122,7 +122,7 @@ jobs:
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
@@ -251,9 +251,9 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
save-if: false
@@ -306,8 +306,8 @@ jobs:
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -328,10 +328,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -351,17 +351,17 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: ci-web
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
@@ -419,15 +419,17 @@ jobs:
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
env:
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
- name: Build documentation
+2 -2
View File
@@ -44,9 +44,9 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: perf-release
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+2 -2
View File
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
+4 -1
View File
@@ -27,9 +27,10 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) 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`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -110,11 +111,13 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
+2 -2
View File
@@ -25,8 +25,8 @@ RUN set -eu; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
+5 -1
View File
@@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
</p>
<h1 align="center">PhotoCraft</h1>
<p align="center">
@@ -202,7 +206,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
+10 -4
View File
@@ -17,11 +17,11 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -322,6 +322,12 @@ impl WebShell {
}
impl eframe::App for WebShell {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
// twin press closes the menu the first one opened (#2095).
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
+20
View File
@@ -34,6 +34,13 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`]
/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon
/// on the Dock after the window opens (#7253).
pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData {
if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() }
}
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
@@ -80,6 +87,19 @@ mod tests {
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
/// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded.
#[test]
fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() {
use std::path::Path;
let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"));
if sets_runtime_icon(bundled) {
let icon = viewport_icon(bundled);
assert!(icon.width >= 256 && icon.width == icon.height);
} else {
assert_eq!(viewport_icon(bundled), egui::IconData::default());
}
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+68 -25
View File
@@ -12,10 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
//! for one launch.
//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows
//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first
//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything,
//! and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -35,6 +35,10 @@ use serde_json::{Value, json};
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
/// Qualcomm's vendor id as its Vulkan driver reports it.
pub const QUALCOMM: u32 = 0x5143;
/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM".
pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
@@ -244,6 +248,18 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com
}
}
/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's
/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on
/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same
/// machine didn't, #7537). The name is for System Info.
fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> {
match vendor {
INTEL => Some("Intel"),
QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"),
_ => None,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
@@ -258,9 +274,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
/// Intel and Qualcomm on Windows ([`dx12_first_on_windows`]). For `cpu`, any GL adapter comes
/// before Vulkan, which is only the fallback for presenting the window (see [`backends`]). On
/// Windows GL comes after every other adapter, WARP included: GL there (VMware's SVGA3D, GL-only
/// old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
@@ -276,7 +293,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
_ => ty,
};
let be = match a.backend {
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
@@ -329,15 +346,18 @@ pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
/// the instance).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
/// The maker's name when picking `chosen` applied the Windows DX12 default of
/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel,
/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance).
pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> {
let c = adapters.get(chosen)?;
if os != Os::Windows || c.backend != wgpu::Backend::Dx12 {
return None;
}
let maker = dx12_first_on_windows(c.vendor)?;
let applied =
(vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker));
applied.then_some(maker)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -349,8 +369,8 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update
/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
@@ -396,9 +416,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
if backend == GpuBackend::Auto
&& let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
{
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
@@ -772,13 +794,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -789,6 +811,27 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the
/// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids.
#[test]
fn qualcomm_on_windows_defaults_to_dx12() {
use wgpu::{Backend as B, DeviceType as T};
let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)];
let i = pick(&adreno, Auto, Os::Windows).unwrap();
assert_eq!(adreno[i].backend, B::Dx12);
assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm"));
// Only Vulkan offered: still Vulkan, and no note.
assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0));
assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None);
// An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default".
let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]];
assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None);
// Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel.
assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None);
// Elsewhere Vulkan stays first.
assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan);
}
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
/// Windows creates the instance without Vulkan.
@@ -824,8 +867,8 @@ mod tests {
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&listed, Auto, Os::Windows).unwrap();
assert_eq!(i, 0);
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel"));
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None);
}
#[test]
+4 -5
View File
@@ -102,9 +102,7 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default();
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
eframe::NativeOptions {
viewport: viewport
.with_app_id(APP_ID)
@@ -803,8 +801,9 @@ fn main() -> eframe::Result {
{
app.set_wgpu(rs);
} else {
// The window still draws with wgpu: record its errors instead of panicking.
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
// The window still draws with wgpu: record its errors instead of panicking,
// and notice when the device is lost (#3438).
app.watch_window_device(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
+16 -7
View File
@@ -17,12 +17,12 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2",
"cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -45,6 +45,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Photoshop", &["psd", "psb", "psdt"]),
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
("Krita", &["kra", "krz"]),
("GIMP", &["xcf", "xcfgz", "gz"]),
("OpenRaster", &["ora"]),
("Paint.NET", &["pdn"]),
("SVG", SVG_EXTS),
@@ -69,7 +70,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Swatches", &["aco", "ase"]),
("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -105,7 +106,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
@@ -386,12 +387,13 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
fn recovery_services(dir: Option<PathBuf>) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
@@ -830,6 +832,9 @@ mod tests {
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"]));
assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD")));
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
@@ -859,6 +864,10 @@ mod tests {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
assert_eq!(
open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts),
Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()])
);
}
#[test]
+304 -34
View File
@@ -3,8 +3,9 @@
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak
//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit
//! token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
@@ -26,7 +27,11 @@
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
@@ -38,6 +43,9 @@ use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
#[cfg(unix)]
const SOCKET_FILE: &str = "instance.sock";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
@@ -59,6 +67,45 @@ pub struct Timing {
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// How the primary listens for later launches.
#[derive(Clone, Debug, PartialEq)]
pub enum Transport {
/// A loopback port (the default): private to the machine, reachable by every process on it.
Loopback,
/// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each
/// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never
/// reaches the first (#1540). `$XDG_RUNTIME_DIR/app/<app id>/` is the same directory in every
/// sandbox of the app, and the user's alone (0700).
#[cfg(unix)]
Socket(PathBuf),
}
/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is
/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port.
pub fn transport() -> Transport {
#[cfg(unix)]
if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) {
return Transport::Socket(path);
}
Transport::Loopback
}
/// `$XDG_RUNTIME_DIR/app/<FLATPAK_ID>/instance.sock`, when both are set.
#[cfg(unix)]
fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let id = flatpak_id.filter(|v| !v.is_empty())?;
let dir = runtime_dir.filter(|v| !v.is_empty())?;
Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE))
}
/// Where the running primary listens, as `instance.json` records it.
#[derive(Clone, Debug, PartialEq)]
enum Endpoint {
Port(u16),
#[cfg(unix)]
Socket(PathBuf),
}
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
@@ -110,6 +157,10 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
/// The socket file to remove on the first shutdown (a Unix socket transport only); taken
/// then, so the second call (`Drop`) can't remove the socket the next primary has bound.
#[cfg(unix)]
socket: Mutex<Option<PathBuf>>,
}
struct Shared {
@@ -151,6 +202,10 @@ impl Primary {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
#[cfg(unix)]
if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() {
let _ = std::fs::remove_file(path);
}
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
@@ -211,9 +266,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]).
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
claim_in(dir, paths, timing, &transport())
}
/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free
/// once a quitting or crashed primary is gone) and then the running primary, wherever
/// `instance.json` says it listens.
pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
@@ -227,7 +288,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing) {
return match serve(dir, lock, timing, transport) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
@@ -235,8 +296,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
if let Some((endpoint, token)) = read_info(dir) {
match send(&endpoint, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
@@ -248,11 +309,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
}
}
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result<Primary, String> {
let (listener, endpoint) = Listener::bind(transport)?;
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, port, &token)?;
write_info(dir, &endpoint, &token)?;
#[cfg(unix)]
let socket = match &endpoint {
Endpoint::Socket(path) => Some(path.clone()),
Endpoint::Port(_) => None,
};
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
@@ -260,7 +325,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
Ok(stream) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
@@ -288,11 +353,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
Ok(Primary {
lock: Mutex::new(Some(lock)),
shared,
#[cfg(unix)]
socket: Mutex::new(socket),
})
}
/// The primary's listener on either transport.
enum Listener {
Tcp(TcpListener),
#[cfg(unix)]
Unix(UnixListener),
}
impl Listener {
fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> {
match transport {
Transport::Loopback => {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
Ok((Listener::Tcp(listener), Endpoint::Port(port)))
}
#[cfg(unix)]
Transport::Socket(path) => {
let at = |e: std::io::Error| format!("{}: {e}", path.display());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(at)?;
}
// This process holds the lock, so a file already there is a crashed primary's.
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path).map_err(at)?;
// The runtime directory is the user's alone already; keep the socket so too.
std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?;
Ok((Listener::Unix(listener), Endpoint::Socket(path.clone())))
}
}
}
fn accept(&self) -> std::io::Result<Stream> {
match self {
Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)),
#[cfg(unix)]
Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)),
}
}
}
/// One connection, on either transport.
enum Stream {
Tcp(TcpStream),
#[cfg(unix)]
Unix(UnixStream),
}
impl Stream {
/// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate).
fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result<Stream> {
match endpoint {
Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp),
#[cfg(unix)]
Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix),
}
}
fn set_read_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_read_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_read_timeout(dur),
}
}
fn set_write_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_write_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_write_timeout(dur),
}
}
/// One read through a shared reference (both socket types read through `&self`).
fn read_once(&self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => (&*s).read(buf),
#[cfg(unix)]
Stream::Unix(s) => (&*s).read(buf),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => s.write(buf),
#[cfg(unix)]
Stream::Unix(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.flush(),
#[cfg(unix)]
Stream::Unix(s) => s.flush(),
}
}
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
@@ -308,7 +479,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing)
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a TcpStream,
stream: &'a Stream,
until: Instant,
}
@@ -317,25 +488,30 @@ impl Read for Deadline<'_> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
self.stream.read_once(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
fn read_request(stream: &Stream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
let mut body = json!({"token": token, "pid": std::process::id()});
match endpoint {
Endpoint::Port(port) => body["port"] = json!(port),
#[cfg(unix)]
Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()),
}
let body = body.to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
@@ -349,10 +525,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
})
}
fn read_info(dir: &Path) -> Option<(u16, String)> {
fn read_info(dir: &Path) -> Option<(Endpoint, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let token = v.get("token")?.as_str()?.to_string();
#[cfg(unix)]
if let Some(socket) = v.get("socket").and_then(Value::as_str) {
return Some((Endpoint::Socket(PathBuf::from(socket)), token));
}
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((port, v.get("token")?.as_str()?.to_string()))
Some((Endpoint::Port(port), token))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
@@ -370,11 +551,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
Some((paths, wait))
}
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them.
fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
@@ -435,6 +615,21 @@ mod tests {
}
}
/// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104).
#[cfg(unix)]
fn short_socket_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE)
}
/// The loopback port the primary under `dir` listens on.
fn port_of(dir: &Path) -> u16 {
match read_info(dir).unwrap().0 {
Endpoint::Port(port) => port,
#[cfg(unix)]
Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
@@ -496,7 +691,7 @@ mod tests {
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1)));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
@@ -516,10 +711,10 @@ mod tests {
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (port, token) = read_info(&dir).unwrap();
let (endpoint, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(port, &token, &["b.psd".into()], short).is_err());
assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -557,7 +752,7 @@ mod tests {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let (port, _) = read_info(&dir).unwrap();
let port = port_of(&dir);
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
@@ -575,7 +770,7 @@ mod tests {
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let (port, _) = read_info(&dir).unwrap();
let port = port_of(&dir);
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
@@ -602,7 +797,7 @@ mod tests {
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
@@ -616,7 +811,7 @@ mod tests {
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
@@ -645,4 +840,79 @@ mod tests {
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
/// directory, which every sandbox of the app shares, with the same lock, token and reply.
#[cfg(unix)]
#[test]
fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("socket");
// Short: a Unix socket path is limited to about 100 bytes (macOS: 104).
let socket = short_socket_path("a");
let transport = Transport::Socket(socket.clone());
let primary = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
};
assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600);
let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap();
assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap()));
assert!(info.get("port").is_none());
let mut ui = primary.connect(&egui::Context::default());
let paths = ["/home/a/one.psd".to_string()];
let (d, p, t) = (dir.clone(), paths.clone(), transport.clone());
let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t));
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// The token still gates the socket.
let (endpoint, _) = read_info(&dir).unwrap();
assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err());
primary.shutdown();
assert!(!socket.exists(), "shutdown removes the socket file");
// The next primary binds the same path; the old one's drop (a second shutdown) must leave it.
let next = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("the lock is free again"),
};
drop(primary);
assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket");
drop(next);
let _ = std::fs::remove_dir_all(&dir);
}
/// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced.
#[cfg(unix)]
#[test]
fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() {
use std::os::unix::fs::FileTypeExt;
let dir = temp_dir("stale-socket");
let socket = short_socket_path("b");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(socket.parent().unwrap()).unwrap();
std::fs::write(&socket, b"not a socket").unwrap();
write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap();
let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) {
Launch::Primary(p) => p,
Launch::Forwarded => panic!("nothing was listening"),
Launch::Standalone(e) => panic!("{e}"),
};
assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(socket.parent().unwrap());
}
/// The socket transport is chosen only inside a Flatpak with a runtime directory.
#[cfg(unix)]
#[test]
fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() {
use std::ffi::OsStr;
let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000"));
assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock")));
assert_eq!(sandbox_socket(None, Some(run)), None);
assert_eq!(sandbox_socket(Some(id), None), None);
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
}
}
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Slice Select tool glyph (not from an icon set): the Slice knife with a pointer. -->
<path d="M10 14l4-4" />
<path d="M14 10l5.6-5.6a2 2 0 0 1 2.8 2.8L16.5 12.5z" />
<path d="M4 12v9l2.5-2.5 2 3.5 1.7-1-2-3.5H12z" />
</svg>

After

Width:  |  Height:  |  Size: 454 B

+8 -1
View File
@@ -33,6 +33,8 @@ fn main() {
depth_map: None,
},
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 12.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 100.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Star },
FilterParams::TiltShift { blur: 15.0, center_x: 0.5, center_y: 0.5, angle: 0.0, focus: 0.1, transition: 0.15 },
FilterParams::IrisBlur { pins: vec![IrisPin::default()] },
@@ -60,7 +62,12 @@ fn main() {
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed());
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
drop(out);
}
}
+140 -25
View File
@@ -437,7 +437,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Chrome => gs(1.0 + g("smoothness") * 0.4) + 2,
F::ConteCrayon => 0,
F::GraphicPen => (g("strokeLength") / 2.0) as i32 + 2,
F::HalftonePattern => gs(0.5 + g("size") * 0.3),
F::HalftonePattern => gs(halftone_cell(e) * 0.15),
F::NotePaper => gs(1.0) + gs(1.2) + 2,
F::Photocopy => gs(1.0 + g("detail") * 0.5),
F::Plaster => gs(0.5 + g("smoothness") * 0.8) + gs(1.5) + 2,
@@ -454,6 +454,55 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
fn cutout_sigma(e: &GalleryEffect) -> f32 {
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
}
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
fn halftone_cell(e: &GalleryEffect) -> f32 {
1.5 + 2.0 * e.get("size").clamp(1.0, 12.0)
}
/// The round-dot spot function `(cos 2πu + cos 2πv) / 2` sampled over a cell and sorted: a
/// value's rank among them is the share of the cell below it, which makes the thresholds even.
fn halftone_dot_ranks() -> &'static [f32] {
static RANKS: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
RANKS.get_or_init(|| {
const N: usize = 128;
let mut v: Vec<f32> = (0..N * N)
.map(|i| {
let (u, w) = (((i % N) as f32 + 0.5) / N as f32, ((i / N) as f32 + 0.5) / N as f32);
((2.0 * PI * u).cos() + (2.0 * PI * w).cos()) / 2.0
})
.collect();
v.sort_by(f32::total_cmp);
v
})
}
/// For each tone (0 = paper … 1 = solid ink, 256 steps), the level `τ` at which the soft
/// threshold `clamp(0.5 + (τ − s) · gain)` over evenly spread thresholds `s` inks exactly that
/// share of a cell on average.
fn halftone_offsets(gain: f32) -> Vec<f32> {
let half = 0.5 / gain.max(1e-3);
// Average ink over s in [0, 1] for level τ: 1 below τ − half, a ramp to 0 at τ + half.
let cover = |tau: f32| {
let (a, z) = (tau - half, tau + half);
let solid = a.clamp(0.0, 1.0);
let (r0, r1) = (a.clamp(0.0, 1.0), z.clamp(0.0, 1.0));
let ramp = if r1 > r0 { (0.5 + tau * gain) * (r1 - r0) - 0.5 * gain * (r1 * r1 - r0 * r0) } else { 0.0 };
solid + ramp
};
(0..256)
.map(|i| {
let target = i as f32 / 255.0;
let (mut lo, mut hi) = (-half - 1.0, 1.0 + half + 1.0);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if cover(mid) < target { lo = mid } else { hi = mid }
}
match i {
0 => -half - 1.0,
255 => 1.0 + half + 1.0,
_ => (lo + hi) / 2.0,
}
})
.collect()
}
fn daubs_radius(e: &GalleryEffect) -> usize {
let wide = matches!(e.choice("brushType"), "wideSharp" | "wideBlurry");
let r = e.get("brushSize") / 2.0 * if wide { 1.5 } else { 1.0 };
@@ -506,6 +555,7 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
res
}
/// Applies one gallery effect to the working window in place.
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
@@ -933,36 +983,46 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
}
}
F::HalftonePattern => {
let size = g("size");
let con = g("contrast") / 50.0;
let cell = 3.0 + size * 2.0;
// A halftone screen that keeps a continuous range of tones: each pixel compares its
// ink (the darkness of the image) with the screen's threshold there, and Contrast is
// how steeply: at 0 the image keeps its tones with the screen faintly through it, at
// 50 it is crisp dots, lines or rings. The thresholds are spread evenly over each cell,
// and each tone is offset so a cell's average ink is exactly that tone: black stays
// solid and white stays paper at any Contrast.
let cell = halftone_cell(e);
let gain = 0.5 + g("contrast") * 0.6;
let offsets = halftone_offsets(gain);
let mut l = win.lum();
blur1(&mut l, w, h, 0.5 + size * 0.3);
blur1(&mut l, w, h, cell * 0.15);
let b = ctx.bounds;
let (cx, cy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let ty = e.choice("patternType");
let dots = halftone_dot_ranks();
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let d = clamp01((1.0 - l[i] - 0.5) * (1.0 + con * 3.0) + 0.5);
let aa = 1.0 / cell;
let ink = match ty {
"circle" => {
let r = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt() / cell;
let q = (r.fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
"line" => {
let q = ((fy / cell).fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
let band = |t: f32| ((t / cell).rem_euclid(1.0) - 0.5).abs() * 2.0;
let spot = match ty {
// Rings round the middle of the image, inked from the middle of each ring.
"circle" => band(((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt()),
// Horizontal lines, inked from the middle of each.
"line" => band(fy),
// Round dots on a screen at 45° (as a black halftone screen is), that grow
// from each cell's centre, meet at 50% as a checkerboard of squares square to
// the image, and fill the cell at 100%.
_ => {
let (u, v) = ((fx / cell).fract() - 0.5, (fy / cell).fract() - 0.5);
let q = (u * u + v * v).sqrt() * 2.0;
let rr = d.sqrt() * 1.15;
smoothstep(rr + aa, rr - aa, q)
let (rx, ry) = ((fx + fy) * std::f32::consts::FRAC_1_SQRT_2, (fy - fx) * std::f32::consts::FRAC_1_SQRT_2);
let (u, v) = ((rx / cell).rem_euclid(1.0), (ry / cell).rem_euclid(1.0));
let f = ((2.0 * PI * u).cos() + (2.0 * PI * v).cos()) / 2.0;
let k = dots.partition_point(|&r| r < f);
k as f32 / dots.len().max(1) as f32
}
};
let tone = clamp01(1.0 - l[i]) * (offsets.len() - 1) as f32;
let (k, f) = (tone.floor() as usize, tone.fract());
let at = |j: usize| offsets.get(j.min(offsets.len() - 1)).copied().unwrap_or(0.5);
let level = at(k) + (at(k + 1) - at(k)) * f;
let ink = clamp01(0.5 + (level - spot) * gain);
win.c[i] = mix(bg, fg, ink);
}
}
@@ -1254,6 +1314,43 @@ mod tests {
}
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
fn halftone_keeps_tones_and_contrast_sets_how_hard() {
// It used to be all ink or paper at any Contrast, with white crosses in the shadows
// (dots could never fill their cell) and mid-greys that came out the wrong darkness.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 60, 60);
let run = |grey: f32, pattern: &str, contrast: f32| {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
s.fill_rect(b, &[grey, grey, grey, 1.0]);
let mut e = GalleryEffect::new(G::HalftonePattern);
assert!(e.set_choice("patternType", pattern));
e.set("size", 4.0);
e.set("contrast", contrast);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
// Ink (1 = foreground black) of every pixel away from the canvas edge.
out.chunks(4).enumerate().filter(|(i, _)| (6..54).contains(&(i % 60)) && (6..54).contains(&(i / 60))).map(|(_, p)| 1.0 - p[0]).collect::<Vec<f32>>()
};
for pattern in ["dot", "circle", "line"] {
for contrast in [0.0, 5.0, 50.0] {
for grey in [0.2, 0.5, 0.8] {
let ink = run(grey, pattern, contrast);
let mean = ink.iter().sum::<f32>() / ink.len() as f32;
assert!((mean - (1.0 - grey)).abs() < 0.06, "{pattern} contrast {contrast}: grey {grey} inks {mean}");
}
let solid = run(0.0, pattern, contrast);
assert!(solid.iter().all(|&v| v > 0.99), "{pattern} contrast {contrast}: black isn't solid");
let paper = run(1.0, pattern, contrast);
assert!(paper.iter().all(|&v| v < 0.01), "{pattern} contrast {contrast}: white isn't paper");
}
let soft = run(0.5, pattern, 0.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
let hard = run(0.5, pattern, 50.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
assert!(hard * 4 < soft, "{pattern}: contrast 50 should be much crisper ({hard} vs {soft} grey pixels)");
}
}
#[test]
fn params_parse_and_clamp() {
for f in crate::GalleryFilter::ALL {
@@ -1305,7 +1402,7 @@ mod tests {
}
/// Visual check: `GALLERY_RAW=src.raw GALLERY_W=480 GALLERY_H=322 cargo test -p photocraft-algo --release gallery_sheet -- --ignored`
/// writes `<key>.raw` (RGB8) beside the input for every filter.
/// writes `<key>.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`).
#[test]
#[ignore]
fn gallery_sheet() {
@@ -1318,11 +1415,29 @@ mod tests {
let b = Rect::new(0, 0, w, h);
s.write_region(b, &data);
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
for f in crate::GalleryFilter::ALL {
let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] };
// Optional: GALLERY_ONLY=<key> renders one filter; GALLERY_SET="key=value,key=name" sets its
// sliders (numbers) or choices (names); GALLERY_TAG is appended to the file name.
let only = std::env::var("GALLERY_ONLY").ok();
let set = std::env::var("GALLERY_SET").unwrap_or_default();
let tag = std::env::var("GALLERY_TAG").unwrap_or_default();
if let Some(k) = &only {
assert!(crate::GalleryFilter::ALL.iter().any(|f| f.key() == k), "GALLERY_ONLY={k} is not a gallery filter");
}
for f in crate::GalleryFilter::ALL.iter().filter(|f| only.as_deref().is_none_or(|k| k == f.key())) {
let mut e = GalleryEffect::new(*f);
for kv in set.split(',').filter(|kv| !kv.is_empty()) {
let (k, v) = kv.split_once('=').unwrap();
// `set` ignores unknown keys; a misspelt one would render the default unnoticed.
assert!(f.params().iter().any(|p| p.key == k), "{k} is not a parameter of {}", f.key());
match v.parse::<f32>() {
Ok(n) => e.set(k, n),
Err(_) => assert!(e.set_choice(k, v), "{k} has no choice {v}"),
}
}
let fp = FilterParams::FilterGallery { effects: vec![e] };
let out = crate::apply_in(&s, &fp, b, b, None, b).read_region(b);
let raw: Vec<u8> = out.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
std::fs::write(dir.join(format!("{}.raw", f.key())), raw).unwrap();
std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap();
}
}
}
+124 -21
View File
@@ -189,6 +189,11 @@ mod shape_tests;
/// Smart Blur: averages only neighbours within `threshold` levels of the
/// centre pixel (so edges stay crisp); Edge Only / Overlay Edge draw the
/// edges (where neighbours differ by more than the threshold) in white.
///
/// Neighbours outside `src` read as zero, as [`Image::get`] does. Pixels whose whole
/// kernel lies inside `src` (all of them, with the usual halo) read neighbours at
/// precomputed offsets into the window instead of by coordinates, and for up to five
/// channels go through [`smart_rows`] (#7921).
pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -201,31 +206,48 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let offs: Vec<(i32, i32)> = (-ri..=ri)
let win = src.rect;
let ww = win.width() as isize;
// Each offset with its distance in samples within the window.
let offs: Vec<(i32, i32, isize)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.map(|(dx, dy)| (dx, dy, (dy as isize * ww + dx as isize) * n as isize))
.collect();
let at = |x: i32, y: i32| win.contains(x, y).then(|| ((y - win.y0) as usize * ww as usize + (x - win.x0) as usize) * n);
let zeros = [0.0f32; MAXC];
let data = &src.data[..];
// The pixel at sample `i`, or zeros outside the window.
let sample = |i: Option<usize>| i.and_then(|i| data.get(i..i + n)).unwrap_or(&zeros[..n]);
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let mut q = [0.0f32; MAXC];
let inner = Rect::new(win.x0.saturating_add(ri), win.y0.saturating_add(ri), win.x1.saturating_sub(ri), win.y1.saturating_sub(ri)).intersect(&out);
let lin: Vec<isize> = offs.iter().map(|o| o.2).collect();
let rows = match (mode, n, ctx.alpha) {
(SmartBlurMode::Normal, 1, false) => smart_rows::<1, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 1, true) => smart_rows::<1, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, false) => smart_rows::<2, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, true) => smart_rows::<2, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, false) => smart_rows::<3, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, true) => smart_rows::<3, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, false) => smart_rows::<4, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, true) => smart_rows::<4, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, false) => smart_rows::<5, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, true) => smart_rows::<5, true>(src, out, inner, &lin, t, &mut res),
_ => Rect::EMPTY,
};
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
if rows.contains(x, y) {
continue;
}
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).abs() > t);
if mode != SmartBlurMode::Normal {
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy))));
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
@@ -237,18 +259,28 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
}
continue;
}
let interior = x - ri >= win.x0 && x + ri < win.x1 && y - ri >= win.y0 && y + ri < win.y1;
let centre = if interior { at(x, y) } else { None };
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
for &(dx, dy, d) in &offs {
let q = sample(match centre {
Some(c) => c.checked_add_signed(d),
None => at(x + dx, y + dy),
});
if differs(q) {
continue;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
if ctx.alpha {
let a = q[n - 1];
for (s, v) in acc.iter_mut().zip(&q[..cc]) {
*s += v * a;
}
acc[n - 1] += a;
} else {
for (s, v) in acc.iter_mut().zip(q) {
*s += v;
}
}
wsum += 1.0;
}
@@ -262,6 +294,77 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
res
}
/// Smart Blur's normal mode over `inner` (pixels whose whole kernel lies in `src`) for `N`
/// channels, the last alpha if `A`; `lin` are the neighbours as sample offsets in the window.
/// Each offset sweeps a whole row, so neighbours load contiguously and a rejected one adds
/// `-0.0` (an exact identity) instead of branching: every pixel sums its neighbours in the same
/// order as [`smart_blur`]'s per-pixel loop, giving the same bits. Returns the area written: the
/// rows finished, empty if the layout doesn't match.
fn smart_rows<const N: usize, const A: bool>(src: &Image, out: Rect, inner: Rect, lin: &[isize], t: f32, res: &mut [f32]) -> Rect {
let win = src.rect;
if src.ch != N || inner.is_empty() || !win.contains_rect(&inner) || !out.contains_rect(&inner) {
return Rect::EMPTY;
}
let cc = if A { N - 1 } else { N };
let (ww, ow, len) = (win.width() as usize, out.width() as usize, inner.width() as usize);
let pixels = len * N;
let mut acc = vec![[0.0f32; N]; len];
let mut wsum = vec![0.0f32; len];
// Rows before `y` are done; a row is written only once all its neighbours were read.
let done = |y: i32| Rect::new(inner.x0, inner.y0, inner.x1, y);
for y in inner.y0..inner.y1 {
let centre = ((y - win.y0) as usize * ww + (inner.x0 - win.x0) as usize) * N;
let Some(me) = src.data.get(centre..centre + pixels) else {
return done(y);
};
let me = me.as_chunks::<N>().0;
acc.fill([0.0; N]);
wsum.fill(0.0);
for &d in lin {
let Some(q) = centre.checked_add_signed(d).and_then(|i| src.data.get(i..i + pixels)) else {
return done(y);
};
for (((s, w), q), m) in acc.iter_mut().zip(wsum.iter_mut()).zip(q.as_chunks::<N>().0).zip(me) {
// As `differs`: a NaN difference is not over the threshold.
let mut far = false;
for c in 0..cc {
far |= (q[c] - m[c]).abs() > t;
}
let keep = !far;
if A {
let a = q[N - 1];
for c in 0..N - 1 {
s[c] += if keep { q[c] * a } else { -0.0 };
}
s[N - 1] += if keep { a } else { -0.0 };
} else {
for c in 0..N {
s[c] += if keep { q[c] } else { -0.0 };
}
}
*w += if keep { 1.0 } else { 0.0 };
}
}
let o = ((y - out.y0) as usize * ow + (inner.x0 - out.x0) as usize) * N;
let Some(dst) = res.get_mut(o..o + pixels) else {
return done(y);
};
for ((px, s), &w) in dst.as_chunks_mut::<N>().0.iter_mut().zip(&acc).zip(&wsum) {
if w > 0.0 {
for c in 0..N {
px[c] = s[c] / w;
}
unpremul_px(px, A);
}
}
}
inner
}
#[cfg(test)]
#[path = "smart_blur_tests.rs"]
mod smart_tests;
// ---------- lens blur ----------
pub(crate) struct LensSpec<'a> {
+19 -2
View File
@@ -411,6 +411,14 @@ pub enum FilterParams {
},
// ---- Render ----
/// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight
/// sRGB RGBA colours; the cloud size follows the bounds.
Clouds {
seed: u32,
difference: bool,
foreground: [f32; 4],
background: [f32; 4],
},
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
Fibers {
variance: f32,
@@ -606,7 +614,13 @@ impl FilterParams {
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
/// empty layer, while still reading no neighbourhood.
pub fn renders_from_nothing(&self) -> bool {
matches!(self, FilterParams::Fibers { .. })
matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. })
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
}
/// Human-readable name.
@@ -653,6 +667,8 @@ impl FilterParams {
FilterParams::Displace { .. } => "Displace",
FilterParams::Shear { .. } => "Shear",
FilterParams::ZigZag { .. } => "ZigZag",
FilterParams::Clouds { difference: false, .. } => "Clouds",
FilterParams::Clouds { difference: true, .. } => "Difference Clouds",
FilterParams::Fibers { .. } => "Fibers",
FilterParams::LensFlare { .. } => "Lens Flare",
FilterParams::LightingEffects { .. } => "Lighting Effects",
@@ -697,7 +713,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)),
FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds,
// Generators and per-pixel renders only need the bounds geometry (always in the context).
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0),
FilterParams::LightingEffects { .. } => r(1.0),
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
@@ -817,6 +833,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)),
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
+46 -1
View File
@@ -654,6 +654,7 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
return out;
}
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
@@ -670,7 +671,15 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let d = field.sample_cubic(gx, gy, &wx, &wy);
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
let linear = field.sample(px, py);
d[0] = d[0] * (1.0 - mask) + linear[0] * mask;
d[1] = d[1] * (1.0 - mask) + linear[1] * mask;
}
}
if d[0] != 0.0 || d[1] != 0.0 {
moved = true;
}
@@ -1376,6 +1385,42 @@ mod tests {
assert!(g.freeze.iter().all(|f| *f == 0.0));
}
#[test]
fn fully_frozen_pixels_keep_their_mapping_when_an_adjacent_area_is_warped() {
let bounds = bounds();
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for y in 0..bounds.height() {
for x in 0..bounds.width() {
src.write_pixel(x as i32, y as i32, &[x as f32 / bounds.width() as f32, y as f32 / bounds.height() as f32, 0.0, 1.0]);
}
}
let mut mask_only = LiquifyField::new(bounds, 2.0);
mask_only.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
assert_eq!(apply_liquify(&src, &mask_only), src);
let mut field = LiquifyField::new(bounds, 2.0);
let mut first_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
first_warp.points = vec![vec![22.0, 32.0], vec![34.0, 32.0]];
field.apply_stroke(&first_warp);
field.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
let frozen_pixel = (37, 32);
assert!(field.freeze_at(f64::from(frozen_pixel.0) + 0.5, f64::from(frozen_pixel.1) + 0.5) > 0.999);
let before = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
assert_ne!(before, src.pixel(frozen_pixel.0, frozen_pixel.1), "freezing must retain the earlier warp");
let neighbor_before = apply_liquify(&src, &field).pixel(43, 32);
let mut adjacent_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 24.0);
adjacent_warp.points = vec![vec![44.0, 32.0], vec![52.0, 32.0]];
field.apply_stroke(&adjacent_warp);
let after = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
let neighbor_after = apply_liquify(&src, &field).pixel(43, 32);
assert_eq!(before, after, "a fully frozen pixel must keep its pre-neighbor-warp mapping");
assert_ne!(neighbor_before, neighbor_after, "an adjacent unfrozen pixel must still move");
}
#[test]
fn twirl_pucker_bloat_and_push_left_have_the_right_sense() {
let at = |tool: LiquifyTool, pts: Vec<Vec<f64>>| {
+54 -2
View File
@@ -1,4 +1,4 @@
//! Render: Fibers, Lens Flare, Lighting Effects.
//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects.
use photocraft_geom::Rect;
@@ -18,6 +18,57 @@ fn value_noise(x: f32, y: f32, seed: u32) -> f32 {
top + (bot - top) * sy
}
fn cloud_hash(x: i32, y: i32, seed: u32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
(h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32
}
/// Smooth value noise, summed over octaves (fBm), in 0..1.
fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 {
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0);
for o in 0..8u32 {
let (fx, fy) = (x * freq, y * freq);
let (ix, iy) = (fx.floor() as i32, fy.floor() as i32);
let (tx, ty) = (fx - ix as f32, fy - iy as f32);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let s = seed.wrapping_add(o * 7919);
let top = cloud_hash(ix, iy, s) + (cloud_hash(ix + 1, iy, s) - cloud_hash(ix, iy, s)) * sx;
let bot = cloud_hash(ix, iy + 1, s) + (cloud_hash(ix + 1, iy + 1, s) - cloud_hash(ix, iy + 1, s)) * sx;
sum += (top + (bot - top) * sy) * amp;
norm += amp;
amp *= 0.5;
freq *= 2.0;
}
// Stretch the bell-shaped sum so the clouds use the full foreground→background range.
((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0)
}
/// Clouds between the foreground and background colours; Difference Clouds subtracts them from
/// the pixels (alpha kept). The cloud size is a quarter of the bounds' longer side, and the
/// pattern is anchored at the bounds' corner (so it moves with a smart object).
pub(crate) fn clouds(src: &Image, out: Rect, ctx: &Ctx, seed: u32, difference: bool, colours: ([f32; 4], [f32; 4])) -> Vec<f32> {
let n = src.ch;
let (fg, bg) = colours;
let base = (ctx.bounds.width().max(ctx.bounds.height()) as f32 / 4.0).clamp(16.0, 512.0);
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (x, y) = (x - ctx.bounds.x0, y - ctx.bounds.y0);
let t = clouds_value(x as f32, y as f32, base, seed);
let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t);
if difference {
let p = rgba(ctx, px);
set_rgba(ctx, px, [(p[0] - c[0]).abs(), (p[1] - c[1]).abs(), (p[2] - c[2]).abs(), p[3]]);
} else {
set_rgba(ctx, px, [c[0], c[1], c[2], 1.0]);
}
}
res
}
/// Fibers: vertical fibrous streaks between the foreground and background
/// colours. Variance sets how quickly the colour varies across fibres (and
/// how short they are); strength stretches each fibre vertically.
@@ -32,8 +83,9 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength:
let fy = fx / (2.0 + st * 1.5) * (0.5 + var / 32.0);
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
// Anchored at the bounds' corner, so the fibres move with a smart object.
let (x, y) = xy(out, i);
let (xf, yf) = (x as f32, y as f32);
let (xf, yf) = ((x - ctx.bounds.x0) as f32, (y - ctx.bounds.y0) as f32);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
+173
View File
@@ -0,0 +1,173 @@
//! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an
//! exact oracle: the offset fast path must give the same bits for every mode, depth and layout.
use super::*;
use photocraft_color::ColorMode;
#[allow(clippy::too_many_arguments)]
fn smart_blur_oracle(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
let r = radius.clamp(0.1, 100.0);
let t = threshold.clamp(0.1, 100.0) / 255.0;
let ri = r.ceil() as i32;
let target = match quality {
BlurQuality::Low => 4,
BlurQuality::Medium => 7,
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let offs: Vec<(i32, i32)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.collect();
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let mut q = [0.0f32; MAXC];
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
if mode != SmartBlurMode::Normal {
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
} else if mode == SmartBlurMode::EdgeOnly {
px[..cc].copy_from_slice(&black[..cc]);
}
if ctx.alpha {
px[n - 1] = a;
}
continue;
}
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
continue;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
}
wsum += 1.0;
}
if wsum > 0.0 {
for c in 0..n {
px[c] = acc[c] / wsum;
}
unpremul_px(px, ctx.alpha);
}
}
res
}
/// Flat blocks with small noise, so some neighbours fall within the threshold and some don't.
fn image(rect: Rect, n: usize, alpha: bool) -> Image {
let w = rect.width() as usize;
let data = (0..w * rect.height() as usize)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
let block = ((x / 5 + y / 7) % 3) as f32 * 0.3;
(0..n).map(move |c| {
let noise = ((i * 31 + c * 13) % 17) as f32 / 160.0;
if alpha && c + 1 == n { [1.0, 0.0, 0.4, 0.75][(x + y) % 4] } else { block + noise }
})
})
.collect();
Image { rect, ch: n, data }
}
fn exact(a: &[f32], b: &[f32], what: &str) {
assert_eq!(a.len(), b.len(), "{what}");
for (i, (a, b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "{what}: sample {i}: {a} != {b}");
}
}
#[test]
fn offset_fast_path_matches_the_per_channel_oracle() {
let mut layouts: Vec<(ColorMode, usize, bool)> = vec![
(ColorMode::Rgb, 3, false),
(ColorMode::Rgb, 4, true),
(ColorMode::Grayscale, 1, false),
(ColorMode::Grayscale, 2, true),
(ColorMode::Cmyk, 4, false),
(ColorMode::Cmyk, 5, true),
(ColorMode::Lab, 4, true),
];
layouts.extend((1..=MAXC).flat_map(|n| [(ColorMode::Multichannel, n, false), (ColorMode::Multichannel, n, true)]));
let win = Rect::new(-7, -4, 33, 30);
for (mode, n, alpha) in layouts {
let src = image(win, n, alpha);
let ctx = Ctx { bounds: win, mode, alpha };
for radius in [0.1f32, 1.0, 3.0, 12.0, 17.5] {
let ri = f32::ceil(radius) as i32;
// The usual tile (the window less the halo), the whole window, and one sticking out of it.
let inner = Rect::new(win.x0 + ri, win.y0 + ri, win.x1 - ri, win.y1 - ri);
for out in [inner, win, Rect::new(-12, -9, 10, 8), Rect::EMPTY] {
for quality in [BlurQuality::Low, BlurQuality::Medium, BlurQuality::High] {
for sm in [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge] {
for threshold in [8.0, 60.0] {
let what = format!("{mode:?} n={n} alpha={alpha} r={radius} {out:?} {quality:?} {sm:?} t={threshold}");
exact(
&smart_blur(&src, out, &ctx, radius, threshold, quality, sm),
&smart_blur_oracle(&src, out, &ctx, radius, threshold, quality, sm),
&what,
);
}
}
}
}
}
}
}
#[test]
fn out_of_window_neighbours_read_as_zero() {
// A one-pixel window: every neighbour is outside and reads as transparent black, so a black
// centre averages with all of them and a white one keeps itself.
let ctx = Ctx { bounds: Rect::new(0, 0, 1, 1), mode: ColorMode::Rgb, alpha: true };
for v in [0.0, 1.0] {
let src = Image { rect: Rect::new(0, 0, 1, 1), ch: 4, data: vec![v, v, v, 1.0] };
let got = smart_blur(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal);
exact(&got, &smart_blur_oracle(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal), "1 px");
}
}
#[test]
fn nan_and_infinite_samples_match_the_oracle() {
// A NaN difference is never over the threshold, so it is averaged in; an infinite one is.
let win = Rect::new(0, 0, 24, 20);
for (n, alpha) in [(3, false), (4, true)] {
let mut src = image(win, n, alpha);
for (i, v) in [(5, f32::NAN), (97, f32::INFINITY), (230, f32::NEG_INFINITY), (311, -0.0)] {
let len = src.data.len();
src.data[(i * n) % len] = v;
}
let ctx = Ctx { bounds: win, mode: ColorMode::Rgb, alpha };
let out = Rect::new(4, 4, 20, 16);
for threshold in [8.0, 100.0] {
exact(
&smart_blur(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
&smart_blur_oracle(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
&format!("n={n} t={threshold}"),
);
}
}
}
+50 -21
View File
@@ -254,6 +254,21 @@ pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
/// The farthest from the origin (px) [`warp_surface`] places a pixel: each side of
/// [`warp_output_bounds`] is clamped to ±2²⁰.
pub const WARP_REACH: i32 = 1 << 20;
/// Would [`warp_surface`] clip the result of warping `src_rect` by `h`: does a warped corner land
/// past [`WARP_REACH`], whether the result is that large or only placed that far out? A caller
/// refuses such a warp rather than lose the pixels beyond it. Empty and singular inputs have no
/// output to clip.
pub fn warp_output_clipped(src_rect: Rect, h: &Homography) -> bool {
let Some(corners) = warp_corners(src_rect, h) else { return false };
let lim = f64::from(WARP_REACH);
let within = |v: f64| v.is_finite() && v.floor() >= -lim && v.ceil() <= lim;
!corners.iter().all(|&(x, y)| within(x) && within(y))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
@@ -264,11 +279,13 @@ fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
)
}
/// The pixel rectangle over the warped `corners` plus a pixel for filter support, each side
/// clamped to [`WARP_REACH`]; `None` when a corner is not finite.
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
return None;
}
let lim = (1 << 20) as f64;
let lim = f64::from(WARP_REACH);
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
@@ -294,7 +311,7 @@ fn warp(
converted = src.try_convert(fmt)?;
&converted
};
let mut out = Surface::new(fmt);
let out = Surface::new(fmt);
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
return Ok(out);
}
@@ -366,7 +383,7 @@ fn warp(
return Ok(None);
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes.
// Short of 100 %: the result is still pruned and handed back after the last tile.
ctl.progress(0.98 * k as f32 / total as f32);
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
@@ -503,33 +520,29 @@ fn warp(
}
Ok(any.then_some((*t, outp)))
};
let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec<f32>)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let mut done = Vec::new();
done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?;
let done = std::sync::Mutex::new(done);
// Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32`
// pixels: kept until the last one was done, they cost 16 bytes a pixel of an RGBA result on
// top of the result's own (#7807).
let out = std::sync::Mutex::new(out);
let warp_and_write = |tile: &Rect| -> Result<(), photocraft_raster::AllocationError> {
if let Some((r, v)) = work(tile)? {
out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).try_write_region(r, &v)?;
}
Ok(())
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> {
if let Some(result) = work(&tile)? {
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
}
Ok(())
})?;
tiles.into_par_iter().try_for_each(|tile| warp_and_write(&tile))?;
}
#[cfg(target_arch = "wasm32")]
for tile in tiles {
if let Some(result) = work(&tile)? {
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
}
warp_and_write(&tile)?;
}
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if ctl.cancelled() {
return Ok(out);
}
for (r, v) in done {
out.try_write_region(r, &v)?;
return Ok(Surface::new(fmt));
}
let mut out = out.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
out.prune();
Ok(out)
}
@@ -551,6 +564,8 @@ mod tests {
}
}
/// The unclipped size matches the clipped bounds where nothing is clipped, saturates when it
/// can't be represented, and is `None` for empty or singular inputs.
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
@@ -563,6 +578,20 @@ mod tests {
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
/// [`warp_output_clipped`] says yes exactly when [`warp_output_bounds`] clamps a side (#7807).
#[test]
fn output_clipped_matches_the_clamped_bounds() {
let src = Rect::new(0, 0, 24, 16);
let at = |dx: f64| Homography([1.0, 0.0, dx, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let edge = f64::from(WARP_REACH) - 24.0;
assert!(!warp_output_clipped(src, &at(edge)), "the right edge lands on the reach");
assert_eq!(warp_output_bounds(src, &at(edge)).map(|b| b.x1), Some(WARP_REACH + 1));
assert!(warp_output_clipped(src, &at(edge + 1.0)), "one pixel past it");
assert_eq!(warp_output_bounds(src, &at(edge + 1.0)).map(|b| b.x1), Some(WARP_REACH + 1), "clamped");
assert!(warp_output_clipped(src, &at(-f64::from(WARP_REACH) - 1.0)));
assert!(!warp_output_clipped(Rect::EMPTY, &at(1e12)), "nothing to clip");
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
+106 -12
View File
@@ -345,21 +345,41 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let src_copy = surf.clone();
let fmt = surf.format();
let n = fmt.channels();
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
let mut dabs = 0;
for p in points {
let mc = metric(&hd_inv, lud, lvd, *p);
// Image-space footprint: map a metric circle's bounding points.
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for k in 0..16 {
let a = k as f64 / 16.0 * std::f64::consts::TAU;
let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd);
let (x, y) = hd.apply(u, v);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1));
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for k in 0..16 {
let a = k as f64 / 16.0 * std::f64::consts::TAU;
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
};
let area = footprint(&hd, lud, lvd, mc);
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
continue;
}
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
// decoding it would cost more than the few per-pixel reads it saves.
win.area = footprint(&hs, lus, lvs, [mc[0] + offset[0], mc[1] + offset[1]]).inflate(2);
let dest_px = u64::from(area.width()) * u64::from(area.height());
if win.area.is_empty()
|| win.area.width() > 4096
|| win.area.height() > 4096
|| u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px
{
win.area = Rect::new(0, 0, 0, 0);
win.data.clear();
} else {
src_copy.read_region_into(win.area, &mut win.data);
}
let mut data = surf.read_region(area);
let w = area.width() as usize;
let mut changed = false;
@@ -376,7 +396,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
// Source pixel at the same metric offset.
let sm = [m[0] + offset[0], m[1] + offset[1]];
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
let s = sample(&src_copy, sx - 0.5, sy - 0.5, n);
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
for c in 0..n {
px[c] += (s[c] - px[c]) * k;
}
@@ -390,11 +410,42 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
dabs
}
fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec<f32> {
/// Pixels of `area` read from the source surface (`read_region` layout).
struct Window {
area: Rect,
data: Vec<f32>,
}
/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), with `corners` (resized to four
/// pixels) as reused scratch. A footprint inside `win` is read from it, any other with one region
/// read from `s`.
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
let n = out.len();
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2));
(0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect()
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
let foot = Rect::new(x0, y0, x1.saturating_add(1), y1.saturating_add(1));
if !win.area.contains_rect(&foot) && foot.width() == 2 && foot.height() == 2 {
// Outside the window (or none): one region read looks the tile up once, where four
// `read_pixel` calls would look it up four times. Same corner order as below.
s.read_region_into(foot, corners);
} else {
corners.resize(4 * n, 0.0);
let w = win.area.width() as usize;
for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() {
let dst = &mut corners[k * n..(k + 1) * n];
if win.area.contains(px, py) {
let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n;
dst.copy_from_slice(&win.data[o..o + n]);
} else {
s.read_pixel(px, py, dst);
}
}
}
let r = corners;
for (c, o) in out.iter_mut().enumerate() {
*o = (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty;
}
}
#[cfg(test)]
@@ -487,4 +538,47 @@ mod tests {
assert_eq!(n, 1);
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
}
#[test]
fn sample_at_the_edge_of_i32_does_not_overflow() {
let s = Surface::new(PixelFormat::RGBA8);
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut out) = (Vec::new(), [1.0; 4]);
sample(&s, &win, 3e9, 3e9, &mut corners, &mut out);
assert_eq!(out, [0.0; 4]);
}
#[test]
fn sample_interpolates_across_tile_edges() {
let mut s = Surface::new(PixelFormat::RGBA8);
let (a, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
// Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge.
s.fill_rect(Rect::new(0, 0, 256, 512), &a);
s.fill_rect(Rect::new(256, 0, 512, 512), &b);
let (mut corners, mut out) = (Vec::new(), [0.0; 4]);
let want = [0.75, 0.0, 0.25, 1.0];
// From the surface, from a window holding the footprint, and from one holding half of it.
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] {
let mut win = Window { area, data: Vec::new() };
s.read_region_into(area, &mut win.data);
sample(&s, &win, 255.25, 255.5, &mut corners, &mut out);
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
}
// Four different corners, one per tile, so both axes of the corner order count.
let mut q = Surface::new(PixelFormat::RGBA8);
for (x, y, px) in [(255, 255, [1.0, 0.0, 0.0, 1.0]), (256, 255, [0.0, 1.0, 0.0, 1.0]), (255, 256, [0.0, 0.0, 1.0, 1.0]), (256, 256, [1.0; 4])] {
q.fill_rect(Rect::new(x, y, x + 1, y + 1), &px);
}
let want = [0.375, 0.25, 0.75, 1.0];
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260)] {
let mut win = Window { area, data: Vec::new() };
q.read_region_into(area, &mut win.data);
sample(&q, &win, 255.25, 255.75, &mut corners, &mut out);
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
}
// Outside every tile, the default (transparent) pixel.
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
sample(&s, &win, -10.5, -10.5, &mut corners, &mut out);
assert_eq!(out, [0.0; 4]);
}
}
+52 -2
View File
@@ -15,7 +15,7 @@
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Surface, Tile};
use photocraft_raster::{Interrupt, Surface, Tile};
use std::sync::Arc;
use crate::transform::{Interp, bicubic};
@@ -75,6 +75,16 @@ pub fn place_source_projective(
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
/// added; pixels outside the warped area are transparent.
pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp) -> Surface {
mesh(src, src_rect, f, interp, &Interrupt::NONE)
}
/// [`warp_mesh_surface`] reporting progress per destination tile; `None` once `ctl` is cancelled.
pub fn warp_mesh_surface_with(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Option<Surface> {
let out = mesh(src, src_rect, f, interp, ctl);
(!ctl.cancelled()).then_some(out)
}
fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Surface {
if src_rect.is_empty() {
let fmt = src.format();
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
@@ -104,7 +114,7 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
tris.push([a, a + w1 + 1, a + w1]);
}
}
warp_triangles(src, src_rect, &verts, &tris, interp)
triangles(src, src_rect, &verts, &tris, interp, ctl)
}
/// Probe lines per axis [`mapped_lengths`] follows through the map.
@@ -149,6 +159,12 @@ fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
/// order is the depth order). Output has `src`'s format with alpha added.
pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp) -> Surface {
triangles(src, src_rect, verts, tris, interp, &Interrupt::NONE)
}
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
/// the remaining tiles; the caller discards the result.
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
let mut fmt = src.format();
let converted;
let src = if fmt.alpha {
@@ -208,7 +224,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let n = fmt.channels();
let a = n - 1;
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
if ctl.cancelled() {
return None;
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are putting the tiles in place.
ctl.progress(0.98 * k as f32 / total as f32);
if tris.is_empty() {
return None;
}
@@ -430,6 +455,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
if ctl.cancelled() {
return out;
}
out.put_tiles(done);
out
}
@@ -503,6 +531,28 @@ mod tests {
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
}
#[test]
fn the_interruptible_mesh_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
let r = s.content_bounds();
let w = Warp::preset(WarpStyle::Arc, 30.0, [0.0, 0.0, 600.0, 400.0]);
let map = |x: f64, y: f64| w.map(x, y);
let reported = std::sync::Mutex::new(0.0f32);
let progress = |p: f32| {
let mut m = reported.lock().unwrap();
*m = m.max(p);
};
let never = || false;
let out = warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::new(&never, &progress)).expect("not cancelled");
let want = warp_mesh_surface(&s, r, &map, Interp::Bicubic);
assert_eq!(out.content_bounds(), want.content_bounds());
assert_eq!(out.read_region(want.content_bounds()), want.read_region(want.content_bounds()));
assert!(*reported.lock().unwrap() > 0.9);
let always = || true;
assert!(warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::cancel_only(&always)).is_none());
}
#[test]
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
+1 -1
View File
@@ -1197,7 +1197,7 @@ mod tests {
break;
}
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
std::thread::sleep(std::time::Duration::from_millis(5));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
}
+18 -1
View File
@@ -340,6 +340,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
}
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
// New Document's Color Profile (#321) can name an .icc file.
"file.new" => &["profile"],
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
_ => &[],
};
@@ -356,6 +358,13 @@ fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") {
return true;
}
// New Document skips exact `working` and `none`; the other working aliases resolve
// the configured working space, which may be an .icc path.
if id == "file.new" {
return params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
matches!(profile, "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
});
}
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
&& params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
matches!(profile, "working" | "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
@@ -759,6 +768,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "adobe-rgb-compat"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "none"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "default"})).is_err());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working-cmyk"})).is_err());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
@@ -887,7 +902,9 @@ mod tests {
}
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
if matches!(id, "prefs.get" | "prefs.reset") {
// These commands return settings or installed-profile paths; they do not accept a host
// filesystem path as an automation input.
if matches!(id, "prefs.get" | "prefs.reset" | "edit.installedProfiles") {
return false;
}
// These registry descriptions refer to document vector paths, not host files.
@@ -257,3 +257,26 @@ fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache()
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read
/// before the edit: a path outside the workspace is refused before that read, and the filter
/// keeps the map it had.
#[test]
fn a_new_displace_map_outside_the_workspace_is_refused_before_it_is_read() {
let f = Fixture::new();
let (mut h, layer) = f.setup(false);
let params = |h: &Headless| {
let d = h.session.active().unwrap();
let LayerContent::Smart(sm) = &d.doc.layer(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params.clone()
};
let before = params(&h);
let map = photocraft_io::smart_map::displace_embedded_map(&before).expect("the open document's map, embedded");
assert!(map.data.iter().all(|v| *v < 0.9), "the grey map");
let doc = h.session.active().unwrap().doc.clone();
let edit = json!({"layer": layer.0, "params": {"mapPath": f.outside("white.png")}});
assert_denied(h.session.execute("layer.smartFilter.setParams", edit.clone()));
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": edit})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
assert_eq!(params(&h), before, "no white map was read into the filter");
}
+78 -9
View File
@@ -261,13 +261,60 @@ fn lab() -> Profile {
/// with `PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release regen`).
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
/// Rec. 709 / sRGB primaries (xy).
const BT709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]];
/// DCI-P3 primaries (xy), used with D65 by Display P3.
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
/// Rec. 2020 primaries (xy).
const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
/// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
fn rec709_trc() -> Curve {
let a = 1.099;
Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] }
}
/// The RGB profile for an ITU-T H.273 (CICP) colour description, as a HEIF `nclx` or a video
/// stream declares it: BT.709, BT.2020 or P3-D65 primaries (codes 1, 9, 12) with the sRGB,
/// BT.709, linear or gamma 2.2/2.8 transfer (13; 1, 6, 14, 15; 8; 4; 5). `linear` means the
/// samples were already decoded to linear light, which is how PQ and HLG (16, 18) images are
/// held, since no ICC curve represents those transfers. `None` for anything else.
pub fn cicp_rgb(primaries: u16, transfer: u16, linear: bool) -> Option<Profile> {
let (prim, name) = match primaries {
1 => (BT709, "BT.709"),
9 => (BT2020, "Rec. ITU-R BT.2020"),
12 => (P3, "Display P3"),
_ => return None,
};
let transfer = if linear { 8 } else { transfer };
let builtin = match (primaries, transfer) {
(1, 13) => Some(Builtin::Srgb),
(1, 8) => Some(Builtin::LinearSrgb),
(12, 13) => Some(Builtin::DisplayP3),
(9, 1 | 6 | 14 | 15) => Some(Builtin::Rec2020),
_ => None,
};
if let Some(b) = builtin {
return Some(b.profile().clone());
}
let (trc, curve) = match transfer {
8 => (Curve::Gamma(1.0), "linear"),
13 => (srgb_trc(), "sRGB curve"),
1 | 6 | 14 | 15 => (rec709_trc(), "BT.709 curve"),
4 => (Curve::Gamma(2.2), "gamma 2.2"),
5 => (Curve::Gamma(2.8), "gamma 2.8"),
_ => return None,
};
Some(rgb(&format!("{name} {curve} (PhotoCraft)"), prim, d65(), trc))
}
fn build(b: Builtin) -> Profile {
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
BT709,
P3,
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
BT2020,
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
);
match b {
@@ -279,12 +326,7 @@ fn build(b: Builtin) -> Profile {
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
let trc = Curve::Parametric { kind: 3, p: [1.0 / 0.45, 1.0 / a, (a - 1.0) / a, 1.0 / 4.5, 0.081, 0.0, 0.0] };
rgb(b.description(), bt2020, d65(), trc)
}
Builtin::Rec2020 => rgb(b.description(), bt2020, d65(), rec709_trc()),
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
@@ -294,3 +336,30 @@ fn build(b: Builtin) -> Profile {
Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cicp_maps_onto_the_builtins_and_linear_bt2020() {
let desc = |p: Option<Profile>| p.map(|p| p.description);
assert_eq!(desc(cicp_rgb(1, 13, false)), Some(Builtin::Srgb.description().to_string()));
assert_eq!(desc(cicp_rgb(12, 13, false)), Some(Builtin::DisplayP3.description().to_string()));
assert_eq!(desc(cicp_rgb(9, 14, false)), Some(Builtin::Rec2020.description().to_string()));
assert_eq!(desc(cicp_rgb(1, 18, true)), Some(Builtin::LinearSrgb.description().to_string()));
// An HLG BT.2020 image decoded to linear light: BT.2020's colorants, a linear curve.
let hlg = cicp_rgb(9, 18, true).unwrap();
assert_eq!(hlg.trc, Some([Curve::Gamma(1.0), Curve::Gamma(1.0), Curve::Gamma(1.0)]));
assert_eq!(hlg.matrix, Builtin::Rec2020.profile().matrix);
assert!(hlg.description.contains("BT.2020") && hlg.description.contains("linear"), "{}", hlg.description);
assert!(Profile::parse(&hlg.to_bytes()).is_ok(), "encodes as a valid ICC profile");
// HLG and PQ have no ICC curve; unknown primaries have no profile.
assert!(cicp_rgb(9, 18, false).is_none());
assert!(cicp_rgb(9, 16, false).is_none());
assert!(cicp_rgb(2, 13, false).is_none());
assert!(cicp_rgb(u16::MAX, u16::MAX, true).is_none());
}
}
+2
View File
@@ -66,6 +66,8 @@ the same.
Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the
embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode.
`bmp_palette` returns the colours the header declares, not the decoder's 256-entry padding.
`encode_bmp_indexed` writes the inverse: indices plus a table as an 8-bit palette BMP.
## Documented asymmetries and limitations
+182
View File
@@ -0,0 +1,182 @@
//! ITU-T H.273 colour descriptions (CICP: coding-independent code points), as HEIF `colr`
//! `nclx` boxes and video streams declare them, and the HDR transfer functions of ITU-R BT.2100.
//!
//! PQ and HLG can't be written as an ICC tone curve, so an image using them is decoded here to
//! linear light (32-bit float) instead: diffuse white (BT.2408's 203 cd/m²) becomes 1.0 and the
//! highlights above it are kept as values over 1.0, as Photoshop holds HDR in a 32-bit document.
//! The caller tags the result with a linear profile on the declared primaries.
use crate::error::CodecError;
use crate::image::{Image, SampleType};
/// A colour description: the four H.273 code points as written in the file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cicp {
/// Colour primaries: 1 BT.709, 9 BT.2020, 12 P3-D65…
pub primaries: u16,
/// Transfer characteristics: 13 sRGB, 1/6/14/15 BT.709, 8 linear, 16 PQ, 18 HLG…
pub transfer: u16,
/// Matrix coefficients.
pub matrix: u16,
/// Full-range samples rather than studio swing.
pub full_range: bool,
}
impl Cicp {
/// SMPTE ST 2084 / BT.2100 perceptual quantizer.
pub const TRANSFER_PQ: u16 = 16;
/// ARIB STD-B67 / BT.2100 hybrid log-gamma.
pub const TRANSFER_HLG: u16 = 18;
/// PQ or HLG: samples that [`linearize`] decodes to linear light.
pub fn is_hdr(self) -> bool {
matches!(self.transfer, Cicp::TRANSFER_PQ | Cicp::TRANSFER_HLG)
}
}
/// The luminance that becomes 1.0: BT.2408's HDR reference (diffuse) white.
pub const REFERENCE_WHITE_NITS: f32 = 203.0;
/// The nominal display peak HLG is rendered for (BT.2100's reference display).
pub const HLG_PEAK_NITS: f32 = 1000.0;
/// PQ's absolute peak.
pub const PQ_PEAK_NITS: f32 = 10_000.0;
/// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1).
pub fn hlg_inverse_oetf(e: f32) -> f32 {
const A: f32 = 0.178_832_77;
const B: f32 = 1.0 - 4.0 * A;
const C: f32 = 0.559_910_7;
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
if e <= 0.5 { e * e / 3.0 } else { (((e - C) / A).exp() + B) / 12.0 }
}
/// BT.2100 HLG signal to display light relative to [`REFERENCE_WHITE_NITS`]: the inverse OETF,
/// then the OOTF for a [`HLG_PEAK_NITS`] display (system gamma 1.2, zero black level), whose
/// luminance uses the weights of the `luma` primaries.
pub fn hlg_to_linear(rgb: [f32; 3], luma: [f32; 3]) -> [f32; 3] {
let s = rgb.map(hlg_inverse_oetf);
let ys = luma[0] * s[0] + luma[1] * s[1] + luma[2] * s[2];
// γ = 1.2 + 0.42·log10(Lw / 1000) is exactly 1.2 at 1000 cd/m².
let gain = if ys > 0.0 { HLG_PEAK_NITS * ys.powf(0.2) / REFERENCE_WHITE_NITS } else { 0.0 };
s.map(|v| v * gain)
}
/// SMPTE ST 2084 (PQ) signal `E'` (0..1) to display light relative to [`REFERENCE_WHITE_NITS`].
pub fn pq_to_linear(e: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
let p = e.powf(1.0 / M2);
let y = ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1);
y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS
}
/// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the
/// space BT.2100 defines HLG in).
fn luma_weights(code: u16) -> [f32; 3] {
match code {
1 => [0.2126, 0.7152, 0.0722],
12 => [0.2290, 0.6917, 0.0793],
_ => [0.2627, 0.6780, 0.0593],
}
}
/// `img` (RGB or RGBA, decoded with `cicp`'s PQ or HLG transfer) as linear light in 32-bit float,
/// alpha unchanged. Its ICC profile is dropped: whatever it said described the encoded samples,
/// not linear light. Anything else comes back unchanged.
pub fn linearize(img: Image, cicp: Cicp) -> Result<Image, CodecError> {
let layout = img.layout();
if !cicp.is_hdr() || !layout.is_rgb() {
return Ok(img);
}
let (w, h) = img.dimensions();
let ch = layout.channels();
let mut v = img.to_normalized();
let luma = luma_weights(cicp.primaries);
for px in v.chunks_exact_mut(ch) {
let [r, g, b, ..] = *px else { continue };
let out = if cicp.transfer == Cicp::TRANSFER_HLG { hlg_to_linear([r, g, b], luma) } else { [r, g, b].map(pq_to_linear) };
px[..3].copy_from_slice(&out);
}
let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?;
out.meta = img.meta;
out.warnings = img.warnings;
out.cicp = Some(cicp);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image::ChannelLayout;
const BT2020: [f32; 3] = [0.2627, 0.6780, 0.0593];
fn close(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn hlg_inverse_oetf_matches_bt2100() {
assert_eq!(hlg_inverse_oetf(0.0), 0.0);
// The two segments meet at E' = 0.5, E = 1/12.
assert!(close(hlg_inverse_oetf(0.5), 1.0 / 12.0, 1e-6));
assert!(close(hlg_inverse_oetf(0.500_001), 1.0 / 12.0, 1e-5));
assert!(close(hlg_inverse_oetf(1.0), 1.0, 1e-4));
// BT.2408's HLG reference white, 75 % signal, is E ≈ 0.265.
assert!(close(hlg_inverse_oetf(0.75), 0.2650, 1e-3));
for bad in [f32::NAN, f32::INFINITY, -1.0] {
assert!(hlg_inverse_oetf(bad).is_finite());
}
}
#[test]
fn hlg_reference_white_is_one_and_highlights_are_kept() {
// BT.2408: a 75 % HLG grey on a 1000 cd/m² display is 203 cd/m², our 1.0.
let white = hlg_to_linear([0.75; 3], BT2020);
assert!(white.iter().all(|&v| close(v, 1.0, 0.005)), "{white:?}");
// Peak signal is the display peak: 1000 / 203.
let peak = hlg_to_linear([1.0; 3], BT2020);
assert!(peak.iter().all(|&v| close(v, 1000.0 / 203.0, 0.01)), "{peak:?}");
// 50 % grey: 1000 · (1/12)^1.2 ≈ 50.7 cd/m².
let mid = hlg_to_linear([0.5; 3], BT2020);
assert!(mid.iter().all(|&v| close(v * 203.0, 50.71, 0.1)), "{mid:?}");
assert_eq!(hlg_to_linear([0.0; 3], BT2020), [0.0; 3]);
// A saturated colour keeps its hue: only red lit stays only red.
let red = hlg_to_linear([0.75, 0.0, 0.0], BT2020);
assert!(red[0] > 0.0 && red[1] == 0.0 && red[2] == 0.0, "{red:?}");
}
#[test]
fn pq_matches_st2084() {
assert_eq!(pq_to_linear(0.0), 0.0);
assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05));
// 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408).
assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807));
assert!(pq_to_linear(f32::NAN).is_finite());
}
#[test]
fn linearize_decodes_hlg_to_float_and_keeps_alpha() {
let hlg = Cicp { primaries: 9, transfer: Cicp::TRANSFER_HLG, matrix: 9, full_range: true };
let three_quarters = (0.75f32 * 65535.0).round() as u16;
let img = Image::from_u16(2, 1, ChannelLayout::Rgba, &[three_quarters, three_quarters, three_quarters, 65535, 0, 0, 0, 32768])
.unwrap()
.with_icc(Some(b"encoded-profile".to_vec()));
let out = linearize(img, hlg).unwrap();
assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg)));
let v = out.to_normalized();
assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}");
assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}");
}
#[test]
fn linearize_leaves_sdr_untouched() {
let srgb = Cicp { primaries: 1, transfer: 13, matrix: 6, full_range: false };
let img = Image::from_u16(1, 1, ChannelLayout::Rgb, &[1, 2, 3]).unwrap();
assert_eq!(linearize(img.clone(), srgb).unwrap(), img);
}
}
+11 -1
View File
@@ -36,6 +36,7 @@ fn err(e: photocraft_heif::Error) -> CodecError {
#[cfg(feature = "heif")]
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
use crate::cicp::Cicp;
use crate::image::{ChannelLayout, Metadata, SampleType};
// The container alone: the declared size is checked before any pixel is decoded.
@@ -51,12 +52,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> R
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
img.icc = decoded.icc;
img.cicp = decoded.nclx.map(|n| Cicp { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix, full_range: n.full_range });
img.meta = Metadata {
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
..Default::default()
};
Ok(img)
// A 10-bit HDR HEIC (BT.2100 HLG or PQ) opens as linear light in 32-bit float (#6752): no
// ICC curve can describe those transfers, and an 8/16-bit document would clip the highlights.
match img.cicp.filter(|c| c.is_hdr()) {
Some(c) => {
limits.check(decoded.width, decoded.height, layout, SampleType::F32)?;
crate::cicp::linearize(img, c)
}
None => Ok(img),
}
}
#[cfg(test)]
+143 -6
View File
@@ -7,8 +7,9 @@
//! these wins: the APP13 Photoshop ResolutionInfo, the XMP `tiff:` resolution, the EXIF IFD0
//! resolution, and last the JFIF density (a unit-0 JFIF density is only an aspect ratio and
//! never counts). Cameras record their resolution in EXIF only, and many tools write a default
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export the JFIF
//! density, the EXIF and the XMP resolution are all written as the image's resolution.
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export RGB and gray
//! images carry density in JFIF; CMYK omits JFIF APP0, which is invalid for four components, and
//! carries an available density in EXIF instead.
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
@@ -263,7 +264,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
enc.set_sampling_factor(sampling);
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e);
if opts.embed_metadata {
if let Some((x, y)) = img.meta.dpi
if img.layout() != ChannelLayout::Cmyk
&& let Some((x, y)) = img.meta.dpi
&& x >= 1.0
&& y >= 1.0
{
@@ -271,7 +273,16 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
}
// Metadata too large for its one segment is left out (and reported by the fidelity
// warnings) rather than failing the whole export.
if let Some(seg) = img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi)) {
let exif = if img.layout() == ChannelLayout::Cmyk {
match img.meta.exif.as_deref() {
Some(exif) => exif_segment_with_resolution(exif, img.meta.dpi),
None if usable_dpi(img.meta.dpi) => exif_segment_with_resolution(&[], img.meta.dpi),
None => None,
}
} else {
img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi))
};
if let Some(seg) = exif {
enc.add_app_segment(1, &seg).map_err(e)?;
}
if let Some(seg) = img.meta.xmp.as_deref().and_then(|x| xmp_segment(x, img.meta.dpi)) {
@@ -284,9 +295,58 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
enc.add_icc_profile(icc).map_err(e)?;
}
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
if img.layout() == ChannelLayout::Cmyk {
strip_jfif_app0(&mut out);
}
Ok(out)
}
/// JFIF APP0 requires one or three image components. A four-component CMYK JPEG must not carry
/// that marker; keep Adobe APP14, which identifies the CMYK encoding instead.
fn strip_jfif_app0(bytes: &mut Vec<u8>) {
if !bytes.starts_with(&[0xff, 0xd8]) {
return;
}
let mut i = 2usize;
loop {
if bytes.get(i) != Some(&0xff) {
return;
}
let Some(&marker) = bytes.get(i.saturating_add(1)) else { return };
if matches!(marker, 0xda | 0xd9) {
return;
}
if marker == 0xff {
i = i.saturating_add(1);
continue;
}
if marker == 0x01 || (0xd0..=0xd8).contains(&marker) {
i = i.saturating_add(2);
continue;
}
let Some(length_bytes) = i.checked_add(2).and_then(|at| bytes.get(at..at.checked_add(2)?)) else { return };
let Ok(length_bytes) = <[u8; 2]>::try_from(length_bytes) else { return };
let length = usize::from(u16::from_be_bytes(length_bytes));
if length < 2 {
return;
}
let Some(end) = i.checked_add(2).and_then(|at| at.checked_add(length)) else { return };
if end > bytes.len() {
return;
}
if marker == 0xe0 && bytes.get(i.saturating_add(4)..i.saturating_add(9)) == Some(b"JFIF\0") {
bytes.drain(i..end);
} else {
i = end;
}
}
}
/// Return whether both resolution values are finite and within the supported metadata range.
fn usable_dpi(dpi: Option<(f32, f32)>) -> bool {
dpi.is_some_and(|(x, y)| x.is_finite() && y.is_finite() && (1.0..=1_000_000.0).contains(&x) && (1.0..=1_000_000.0).contains(&y))
}
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
const MAX_SEGMENT: usize = 65533;
@@ -297,12 +357,18 @@ const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
pub(crate) fn exif_segment(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
// The pixels are written as they are shown: never let a viewer rotate them again; and the
// resolution is the one written to JFIF, not the source's (#1691).
// The pixels are written as they are shown: never let a viewer rotate them again; and store
// the output resolution, not the source's (#1691).
let seg = [EXIF_HEADER, export_exif(exif, dpi).as_ref()].concat();
(seg.len() <= MAX_SEGMENT).then_some(seg)
}
/// Build an EXIF APP1 segment, adding missing resolution tags when the source metadata is valid.
fn exif_segment_with_resolution(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
let seg = [EXIF_HEADER, crate::resolution::export_exif_adding_missing_resolution(exif, dpi).as_ref()].concat();
(seg.len() <= MAX_SEGMENT).then_some(seg)
}
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
/// properties are left out; `None` when it still doesn't fit.
pub(crate) fn xmp_segment(xmp: &str, dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
@@ -347,3 +413,74 @@ fn remove_element(xmp: &str, name: &str) -> String {
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Format, Metadata};
/// Build a little-endian TIFF with an empty IFD0 for metadata tests.
fn empty_tiff() -> Vec<u8> {
// Little-endian TIFF with an empty IFD0 and no next IFD.
b"II*\0\x08\0\0\0\0\0\0\0\0\0\0\0".to_vec()
}
/// Return whether the JPEG begins with a JFIF APP0 segment.
fn has_jfif_app0(bytes: &[u8]) -> bool {
bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0")
}
/// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker.
#[test]
fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() {
let img = Image::from_raw(2, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 32, 64, 96, 255, 224, 192, 160]).unwrap();
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let meta = scan_metadata(&bytes);
assert!(!has_jfif_app0(&bytes));
assert_eq!(meta.frame, Some((2, 1, 4)));
assert_eq!(meta.adobe_transform, Some(0));
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().layout(), ChannelLayout::Cmyk);
}
/// CMYK JPEG output stores image resolution in EXIF when the source has no EXIF.
#[test]
fn cmyk_jpeg_preserves_dpi_without_source_exif() {
let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap();
img.meta.dpi = Some((300.0, 150.0));
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
assert!(!has_jfif_app0(&bytes));
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((300.0, 150.0)));
}
/// CMYK JPEG output adds missing EXIF resolution tags to existing metadata.
#[test]
fn cmyk_jpeg_preserves_dpi_with_source_exif_without_resolution_tags() {
let mut img = Image::from_raw(1, 1, ChannelLayout::Cmyk, SampleType::U8, vec![0, 0, 0, 0]).unwrap();
img.meta = Metadata { exif: Some(empty_tiff()), dpi: Some((240.0, 120.0)), ..Default::default() };
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
assert_eq!(crate::decode_as(Format::Jpeg, &bytes).unwrap().meta.dpi, Some((240.0, 120.0)));
}
/// Adding missing resolution tags makes them readable from the rewritten IFD0.
#[test]
fn exif_with_resolution_adds_missing_ifd0_entries() {
let exif = empty_tiff();
let rewritten = crate::resolution::export_exif_adding_missing_resolution(&exif, Some((240.0, 120.0)));
assert_eq!(crate::resolution::exif_resolution(&rewritten), Some((240.0, 120.0)));
}
/// RGB JPEG output continues to store image resolution in JFIF density fields.
#[test]
fn rgb_jpeg_keeps_jfif_density() {
let mut img = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U8, vec![12, 34, 56]).unwrap();
img.meta.dpi = Some((96.0, 96.0));
let bytes = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
assert!(has_jfif_app0(&bytes));
assert_eq!(scan_metadata(&bytes).dpi, Some((96.0, 96.0)));
}
}
+18 -1
View File
@@ -20,6 +20,10 @@ use super::tiff_ifd::{File, Ifd};
pub(crate) const TAG_EXIF_IFD: u16 = 34665;
/// IFD0's pointer to the GPS directory.
pub(crate) const TAG_GPS_IFD: u16 = 34853;
/// The Exif directory's PixelXDimension and PixelYDimension: the size of the image the
/// directory describes.
const TAG_PIXEL_X_DIMENSION: u16 = 40962;
const TAG_PIXEL_Y_DIMENSION: u16 = 40963;
/// The Exif directory's pointer to the Interoperability directory (not carried: it holds only
/// the interoperability index, and a copied pointer would point nowhere).
const TAG_INTEROP_IFD: u16 = 40965;
@@ -92,7 +96,20 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
/// is left are left out (the small descriptive directories come first, so a huge maker note
/// cannot crowd out the GPS position).
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
let f = File::parse(b)?;
read_file(File::parse(b)?)
}
/// The EXIF parts of a camera raw built on TIFF (NEF, ARW, CR2, DNG, Panasonic RW2), as
/// [`read`] gives them, without the Exif directory's PixelXDimension and PixelYDimension: in a
/// raw they give the sensor's or the preview's size, not the developed image's.
pub(crate) fn read_raw(b: &[u8]) -> Option<ExifDirs> {
let mut dirs = read_file(File::parse_raw(b)?)?;
dirs.exif.retain(|f| !matches!(f.tag, TAG_PIXEL_X_DIMENSION | TAG_PIXEL_Y_DIMENSION));
Some(dirs)
}
fn read_file(f: File<'_>) -> Option<ExifDirs> {
let b = f.b;
if f.big {
return None;
}
+14
View File
@@ -119,6 +119,20 @@ impl<'a> File<'a> {
Some(f)
}
/// [`parse`](Self::parse), also taking a Panasonic RW2 raw, whose classic little-endian TIFF
/// header has 0x55 (`IIU\0`) in place of 42.
pub fn parse_raw(b: &'a [u8]) -> Option<Self> {
if let Some(f) = Self::parse(b) {
return Some(f);
}
let mut f = File { b, le: true, big: false, first_ifd: 0 };
if b.get(0..2)? != b"II" || f.u16_at(2)? != 0x55 {
return None;
}
f.first_ifd = u64::from(f.u32_at(4)?);
Some(f)
}
/// Length of the header; no directory can start inside it.
pub fn header_len(&self) -> u64 {
if self.big { 16 } else { 8 }
+87 -1
View File
@@ -35,7 +35,51 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
let mut palette = decoder.get_palette().map(|palette| palette.to_vec());
// The decoder always hands back 256 entries (it pads a short table with black to survive
// corrupt files); the file's own count is what the document's colour table should hold.
if let (Some(p), Some(n)) = (palette.as_mut(), bmp_palette_len(bytes)) {
p.truncate(n.max(1));
}
Ok(palette)
}
/// How many colours a palette BMP's header declares: `biClrUsed`, or 2^bits when it is 0.
/// `None` for a header this does not recognise, which leaves the table as decoded.
fn bmp_palette_len(bytes: &[u8]) -> Option<usize> {
let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
let (bit_count, used) = match u32_at(14)? {
// OS/2 BITMAPCOREHEADER: no colour count, the bit depth sets the table size.
12 => (u16_at(24)?, 0),
n if n >= 40 => (u16_at(28)?, u32_at(46)?),
_ => return None,
};
if !(1..=8).contains(&bit_count) {
return None;
}
let max = 1usize << bit_count;
Some(if used == 0 { max } else { (used as usize).min(max) })
}
/// Writes an 8-bit palette BMP: `indices` row-major, `palette` RGB entries (1..=256). The
/// counterpart of [`bmp_palette`], for Indexed Color documents.
pub(crate) fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result<Vec<u8>, CodecError> {
let f = Format::Bmp;
if width == 0 || height == 0 || u64::from(width) * u64::from(height) != indices.len() as u64 {
return Err(CodecError::InvalidImage("index data does not match the size".into()));
}
if palette.is_empty() || palette.len() > 256 {
return Err(CodecError::InvalidImage("a palette BMP holds 1 to 256 colours".into()));
}
if indices.iter().any(|&i| usize::from(i) >= palette.len()) {
return Err(CodecError::InvalidImage("palette index out of range".into()));
}
let mut out = Cursor::new(Vec::new());
image::codecs::bmp::BmpEncoder::new(&mut out)
.encode_with_palette(indices, width, height, image::ExtendedColorType::L8, Some(palette))
.map_err(|e| CodecError::encode(f, e))?;
Ok(out.into_inner())
}
/// The `image` decoder for `f` with our limits, its header read and its size checked.
@@ -183,3 +227,45 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
}
Ok(cursor.into_inner())
}
#[cfg(test)]
mod indexed_tests {
use super::*;
#[test]
fn palette_bmp_roundtrips_its_table_and_pixels() {
// 3 px wide: rows are padded to 4 bytes, so a wrong stride shows in the second row.
let pal = [[255, 0, 0], [0, 0, 255], [10, 20, 30]];
let idx = [0u8, 1, 2, 2, 1, 0];
let bytes = encode_bmp_indexed(3, 2, &idx, &pal).unwrap();
assert_eq!(bmp_palette(&bytes).unwrap().as_deref(), Some(&pal[..]), "the table survives, not a 256-entry grey ramp");
let img = decode(Format::Bmp, &bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let rgb = img.convert(ChannelLayout::Rgb, SampleType::U8);
let want: Vec<u8> = idx.iter().flat_map(|&i| pal[usize::from(i)]).collect();
assert_eq!(rgb.data(), want.as_slice());
}
#[test]
fn palette_len_follows_the_header() {
let mut bytes = encode_bmp_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]]).unwrap();
assert_eq!(bmp_palette_len(&bytes), Some(2));
// biClrUsed = 0 means the full 2^bits table.
bytes[46..50].copy_from_slice(&0u32.to_le_bytes());
assert_eq!(bmp_palette_len(&bytes), Some(256));
// A short or unknown header leaves the decoded table alone instead of panicking.
assert_eq!(bmp_palette_len(&bytes[..30]), None);
bytes[14..18].copy_from_slice(&7u32.to_le_bytes());
assert_eq!(bmp_palette_len(&bytes), None);
}
#[test]
fn palette_bmp_rejects_bad_input() {
let pal = [[0, 0, 0], [255, 255, 255]];
assert!(encode_bmp_indexed(3, 2, &[0; 5], &pal).is_err(), "size mismatch");
assert!(encode_bmp_indexed(0, 2, &[], &pal).is_err(), "empty");
assert!(encode_bmp_indexed(2, 1, &[0, 2], &pal).is_err(), "index past the table");
assert!(encode_bmp_indexed(1, 1, &[0], &[]).is_err(), "no colours");
assert!(encode_bmp_indexed(1, 1, &[0], &[[0; 3]; 257]).is_err(), "more than 256 colours");
}
}
+6 -2
View File
@@ -454,6 +454,9 @@ pub struct Image {
data: Vec<u8>,
/// Embedded ICC profile (must describe `layout`'s colour model).
pub icc: Option<Vec<u8>>,
/// The file's CICP colour description (a HEIF `nclx`), when it has one. Only an image whose
/// `icc` is `None` relies on it.
pub cicp: Option<crate::cicp::Cicp>,
pub meta: Metadata,
/// What the decoder noticed about the file: empty for a complete, single image and for
/// images built in memory. Encoders ignore it.
@@ -468,7 +471,7 @@ impl Image {
/// A zero-filled image, or an error if the byte size overflows `usize`.
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() })
}
/// Wrap raw interleaved, native-endian bytes.
@@ -477,7 +480,7 @@ impl Image {
if data.len() != expected {
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
}
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
Ok(Image { width, height, layout, sample, data, icc: None, cicp: None, meta: Metadata::default(), warnings: Vec::new() })
}
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
@@ -646,6 +649,7 @@ impl Image {
sample,
data,
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
cicp: if layout.same_model(self.layout) { self.cicp } else { None },
meta: self.meta.clone(),
warnings: self.warnings.clone(),
}
+20 -1
View File
@@ -13,6 +13,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod cicp;
mod codecs;
mod error;
pub mod exif_color;
@@ -25,7 +26,7 @@ pub mod orientation;
pub mod resolution;
pub mod source_settings;
pub mod web;
pub use crate::cicp::Cicp;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
@@ -139,6 +140,12 @@ pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
via_image::bmp_palette(bytes)
}
/// Encodes `indices` (row-major, one byte per pixel) as an 8-bit palette BMP with the colour
/// table `palette` (1 to 256 entries). Used for Indexed Color documents; BMP has no transparency.
pub fn encode_bmp_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]]) -> Result<Vec<u8>, CodecError> {
via_image::encode_bmp_indexed(width, height, indices, palette)
}
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
@@ -172,6 +179,18 @@ pub fn tiff_page_orientation(bytes: &[u8], page: usize) -> u16 {
tiff::orientation(bytes, Some(page))
}
/// The EXIF block (`Metadata::exif`) of a camera raw built on TIFF (NEF, ARW, CR2, DNG,
/// Panasonic RW2), for the image developed from it: IFD0's descriptive tags (make, model,
/// date…) and the Exif and GPS directories, as a TIFF opens with. Orientation is not part of
/// it, nor the Exif directory's pixel dimensions (the sensor's or the preview's size). When those
/// were all its Exif directory held, the block is IFD0's tags alone, so the camera's make and
/// model are still kept. `None` when the file is not built on a classic TIFF stream or has none
/// of these tags.
pub fn raw_exif(bytes: &[u8]) -> Option<Vec<u8>> {
let dirs = crate::codecs::tiff_exif::read_raw(bytes).filter(|d| d.has_directories() || !d.ifd0.is_empty())?;
Some(crate::codecs::tiff_exif::block(&dirs))
}
/// The limits check and the orientation, shared by every decoder.
fn finish_decode(img: Image, o: u16, opts: &DecodeOptions) -> Result<Image, CodecError> {
let l = &opts.limits;
+142 -10
View File
@@ -159,23 +159,51 @@ pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> {
ppi_pair(x * scale, y * scale)
}
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when there is
/// nothing to change: no resolution entries, already that resolution, or not parseable.
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when it has no
/// resolution entries, already records that resolution, or cannot be parsed.
///
/// Every well-formed XResolution / YResolution (one RATIONAL) gets the new value in place and
/// every well-formed ResolutionUnit (one SHORT) becomes 2 (inch); all other bytes are kept.
/// A resolution entry that can't be rewritten safely (another type or count, or a value outside
/// the block) is removed from IFD0 instead, so no stale value survives: the later entries move
/// up, the entry count and next-IFD pointer follow, and the freed bytes at the table's end are
/// zeroed. Nothing else moves, so every offset in the block stays valid.
/// Existing well-formed XResolution / YResolution (one RATIONAL) entries are rewritten in place,
/// and every well-formed ResolutionUnit (one SHORT) becomes 2 (inch). Malformed entries are
/// removed from IFD0 instead, so no stale value survives; all other bytes are kept.
pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
exif_with_resolution_inner(exif, ppi, false)
}
/// Like [`exif_with_resolution`], but adds X/Y resolution tags when a valid IFD0 has none.
/// Existing TIFF data stays in place, so its offsets remain valid. Used by CMYK JPEG, which
/// cannot carry a JFIF density marker.
pub(crate) fn exif_with_resolution_adding_missing(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
exif_with_resolution_inner(exif, ppi, true)
}
/// Rewrite valid IFD0 resolution tags and optionally append tags that are missing or malformed.
fn exif_with_resolution_inner(exif: &[u8], ppi: (f32, f32), add_missing: bool) -> Cow<'_, [u8]> {
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() {
return Cow::Borrowed(exif);
}
if exif.is_empty() {
if !add_missing {
return Cow::Borrowed(exif);
}
// An image with DPI but no source EXIF still needs a valid EXIF block when the output
// format cannot carry resolution in its own header (CMYK JPEG has no valid JFIF marker).
return Cow::Owned(exif_with_resolution_inner(b"II*\0\x08\0\0\0\0\0\0\0\0\0\0", ppi, true).into_owned());
}
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) };
if ifd.fields.is_empty() || exif_resolution(body).is_some_and(|r| same(r, ppi)) {
if exif_resolution(body).is_some_and(|r| same(r, ppi)) {
return Cow::Borrowed(exif);
}
let missing_xy = ifd.first_of(TAG_X_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none()
|| ifd.first_of(TAG_Y_RESOLUTION).and_then(|f| f.rational(ifd.order, body)).is_none();
if add_missing && missing_xy {
let Some(body) = append_resolution_ifd(body, &ifd, ppi) else { return Cow::Borrowed(exif) };
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
full.extend_from_slice(&body);
return Cow::Owned(full);
}
if ifd.fields.is_empty() {
return Cow::Borrowed(exif);
}
let order = ifd.order;
@@ -207,6 +235,98 @@ pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) }
}
/// Append a replacement IFD0 with fresh resolution tags. Every other entry keeps its original
/// bytes and offsets, since the TIFF block's existing data remains where it was.
fn append_resolution_ifd(body: &[u8], ifd: &Ifd0, ppi: (f32, f32)) -> Option<Vec<u8>> {
let (count_bytes, next_bytes, alignment) = if ifd.big { (8usize, 8usize, 8usize) } else { (2usize, 4usize, 2usize) };
let mut entries = Vec::with_capacity(ifd.count.saturating_add(3));
for index in 0..ifd.count {
let entry = ifd.first.checked_add(index.checked_mul(ifd.entry_bytes)?)?;
let tag = ifd.order.u16(body, entry)?;
if matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
continue;
}
entries.push((tag, body.get(entry..entry.checked_add(ifd.entry_bytes)?)?.to_vec()));
}
let count = entries.len().checked_add(3)?;
if !ifd.big && count > usize::from(u16::MAX) {
return None;
}
let new_ifd = align_up(body.len(), alignment)?;
let table_end = new_ifd.checked_add(count_bytes)?.checked_add(count.checked_mul(ifd.entry_bytes)?)?;
let next_at = ifd.first.checked_add(ifd.count.checked_mul(ifd.entry_bytes)?)?;
let next_ifd = body.get(next_at..next_at.checked_add(next_bytes)?)?;
let rational_at = align_up(table_end.checked_add(next_bytes)?, if ifd.big { 8 } else { 4 })?;
let y_rational_at = rational_at.checked_add(8)?;
if !ifd.big && (new_ifd > u32::MAX as usize || y_rational_at.checked_add(8)? > u32::MAX as usize) {
return None;
}
entries.push((TAG_X_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_X_RESOLUTION, u64::try_from(rational_at).ok()?)?));
entries.push((TAG_Y_RESOLUTION, resolution_entry(ifd.order, ifd.big, TAG_Y_RESOLUTION, u64::try_from(y_rational_at).ok()?)?));
entries.push((TAG_RESOLUTION_UNIT, resolution_entry(ifd.order, ifd.big, TAG_RESOLUTION_UNIT, 2)?));
entries.sort_by_key(|(tag, _)| *tag);
let mut out = body.to_vec();
out.resize(new_ifd, 0);
if ifd.big {
out.extend_from_slice(&put_u64(ifd.order, count as u64));
} else {
out.extend_from_slice(&put_u16(ifd.order, count as u16));
}
for (_, entry) in entries {
out.extend_from_slice(&entry);
}
out.extend_from_slice(next_ifd);
out.resize(rational_at, 0);
let (xn, xd) = rational(ppi.0);
let (yn, yd) = rational(ppi.1);
out.extend_from_slice(&put_u32(ifd.order, xn));
out.extend_from_slice(&put_u32(ifd.order, xd));
out.extend_from_slice(&put_u32(ifd.order, yn));
out.extend_from_slice(&put_u32(ifd.order, yd));
let offset_at: usize = if ifd.big { 8 } else { 4 };
if ifd.big {
out.get_mut(offset_at..offset_at.checked_add(8)?)?.copy_from_slice(&put_u64(ifd.order, new_ifd as u64));
} else {
out.get_mut(offset_at..offset_at.checked_add(4)?)?.copy_from_slice(&put_u32(ifd.order, u32::try_from(new_ifd).ok()?));
}
Some(out)
}
/// One IFD entry. Rational fields store an offset; the SHORT unit is inline.
fn resolution_entry(order: Order, big: bool, tag: u16, value: u64) -> Option<Vec<u8>> {
let entry_bytes = if big { 20 } else { 12 };
let count_at = 4;
let value_at = if big { 12 } else { 8 };
let mut entry = vec![0; entry_bytes];
entry.get_mut(..2)?.copy_from_slice(&put_u16(order, tag));
entry.get_mut(2..4)?.copy_from_slice(&put_u16(order, if tag == TAG_RESOLUTION_UNIT { TYPE_SHORT } else { TYPE_RATIONAL }));
if big {
entry.get_mut(count_at..count_at + 8)?.copy_from_slice(&put_u64(order, 1));
if tag == TAG_RESOLUTION_UNIT {
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
} else {
entry.get_mut(value_at..value_at + 8)?.copy_from_slice(&put_u64(order, value));
}
} else {
entry.get_mut(count_at..count_at + 4)?.copy_from_slice(&put_u32(order, 1));
if tag == TAG_RESOLUTION_UNIT {
entry.get_mut(value_at..value_at + 2)?.copy_from_slice(&put_u16(order, value as u16));
} else {
entry.get_mut(value_at..value_at + 4)?.copy_from_slice(&put_u32(order, u32::try_from(value).ok()?));
}
}
Some(entry)
}
/// Round `value` up to the next `alignment` boundary, returning `None` on overflow.
fn align_up(value: usize, alignment: usize) -> Option<usize> {
value.checked_add((alignment - value % alignment) % alignment)
}
fn put_u16(order: Order, v: u16) -> [u8; 2] {
match order {
Order::Little => v.to_le_bytes(),
@@ -407,13 +527,25 @@ fn exif_without_thumbnail(exif: &[u8]) -> Option<Vec<u8>> {
/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and,
/// when `ppi` is known, the resolution (see [`exif_with_resolution`]).
pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
export_exif_inner(exif, ppi, false)
}
/// Export EXIF for a format without its own resolution field, adding missing X/Y resolution
/// entries to valid metadata when necessary.
pub(crate) fn export_exif_adding_missing_resolution(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
export_exif_inner(exif, ppi, true)
}
/// Normalize EXIF orientation and thumbnails, optionally adding missing output resolution tags.
fn export_exif_inner(exif: &[u8], ppi: Option<(f32, f32)>, add_missing: bool) -> Cow<'_, [u8]> {
let up = upright_exif(exif);
let up = match exif_without_thumbnail(&up) {
Some(v) => Cow::Owned(v),
None => up,
};
let Some(ppi) = ppi else { return up };
if let Cow::Owned(v) = exif_with_resolution(&up, ppi) {
let result = if add_missing { exif_with_resolution_adding_missing(&up, ppi) } else { exif_with_resolution(&up, ppi) };
if let Cow::Owned(v) = result {
return Cow::Owned(v);
}
up
+61
View File
@@ -213,6 +213,48 @@ pub fn rgb_to_gray(rgb: [f32; 3]) -> f32 {
0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
}
/// HSB of an sRGB colour with the hue in sextants (0..6) instead of degrees, and hue 0 for greys.
pub fn rgb_to_hsb_sextant(rgb: [f32; 3]) -> [f32; 3] {
let (mx, mn) = (rgb[0].max(rgb[1]).max(rgb[2]), rgb[0].min(rgb[1]).min(rgb[2]));
let d = mx - mn;
let h = if d <= 0.0 {
0.0
} else if mx == rgb[0] {
((rgb[1] - rgb[2]) / d).rem_euclid(6.0)
} else if mx == rgb[1] {
(rgb[2] - rgb[0]) / d + 2.0
} else {
(rgb[0] - rgb[1]) / d + 4.0
};
[h, if mx > 0.0 { d / mx } else { 0.0 }, mx]
}
/// The sRGB colour at a hue sextant (0..6) with the given chroma, raised by offset on every channel.
pub fn rgb_from_hue_sextant(sextant: f32, chroma: f32, offset: f32) -> [f32; 3] {
let x = chroma * (1.0 - (sextant.rem_euclid(2.0) - 1.0).abs());
let (r, g, b) = match sextant as u32 {
0 => (chroma, x, 0.0),
1 => (x, chroma, 0.0),
2 => (0.0, chroma, x),
3 => (0.0, x, chroma),
4 => (x, 0.0, chroma),
_ => (chroma, 0.0, x),
};
[r + offset, g + offset, b + offset]
}
/// HSB to sRGB, the hue in degrees (wrapping) and saturation and brightness in 0..1, as Photoshop's HSB.
pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
let chroma = v * s;
rgb_from_hue_sextant(h.rem_euclid(360.0) / 60.0, chroma, v - chroma)
}
/// sRGB to HSB, the hue in degrees 0..360 and saturation and brightness in 0..1.
pub fn rgb_to_hsb(rgb: [f32; 3]) -> [f32; 3] {
let [h, s, v] = rgb_to_hsb_sextant(rgb);
[60.0 * h, s, v]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -294,6 +336,7 @@ mod tests {
});
}
/// Lab round-trips sRGB and puts white at L 100 and black at L 0.
#[test]
fn lab_roundtrip_and_anchors() {
let white = srgb_to_lab([1.0, 1.0, 1.0]);
@@ -307,4 +350,22 @@ mod tests {
}
}
}
/// HSB hits the primary and grey anchors and round-trips sRGB.
#[test]
fn hsb_conversions_match_known_colours_and_round_trip() {
assert_eq!(hsb_to_rgb([120.0, 1.0, 1.0]), [0.0, 1.0, 0.0]);
assert_eq!(hsb_to_rgb([0.0, 0.0, 0.5]), [0.5, 0.5, 0.5]);
assert_eq!(rgb_to_hsb([0.0, 0.0, 1.0]), [240.0, 1.0, 1.0]);
assert_eq!(rgb_to_hsb([0.25, 0.25, 0.25]), [0.0, 0.0, 0.25]);
for r in 0..=8 {
for g in 0..=8 {
for b in 0..=8 {
let c = [r as f32 / 8.0, g as f32 / 8.0, b as f32 / 8.0];
let back = hsb_to_rgb(rgb_to_hsb(c));
assert!(back.iter().zip(c).all(|(x, y)| (x - y).abs() < 1e-5), "{c:?} -> {back:?}");
}
}
}
}
}
@@ -26,6 +26,7 @@ fn document_with_strokes(layer_count: usize) -> Result<Document, Box<dyn Error>>
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)),
overprint: false,
}));
layer.advanced.transparency_shapes = false;
document.layers.push(layer);
+3
View File
@@ -289,6 +289,7 @@ fn inside_stroke() -> Effect {
size: 2.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})
}
@@ -298,6 +299,7 @@ fn outside_stroke() -> Effect {
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})
}
@@ -318,6 +320,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
size: 6.0,
position,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})];
l.blend = blend;
l.fill_opacity = fill;
+55 -2
View File
@@ -997,7 +997,9 @@ pub fn hash_noise(x: i64, y: i64) -> f32 {
}
/// Effect noise (Photoshop's Noise slider): monochrome speckle in the coverage,
/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched.
/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched. The speckle is
/// scaled by the coverage, so it fades out with the effect instead of lighting up the blur's
/// near-zero tail out to the edge of its rectangular reach (#7833).
fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) {
if !amount.is_finite() || amount <= 0.0 {
return;
@@ -1009,7 +1011,7 @@ fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) {
continue;
}
let n = hash_noise(i64::from(x0 + x as i32), i64::from(y0 + y as i32));
*v = (*v + (n - 0.5) * 2.0 * amount).clamp(0.0, 1.0);
*v = (*v * (1.0 + (n - 0.5) * 2.0 * amount)).clamp(0.0, 1.0);
}
}
}
@@ -2521,6 +2523,7 @@ mod tests {
altitude: 30.0,
use_global_light: false,
gloss_contour: Contour::Linear,
gloss_anti_alias: false,
highlight: photocraft_doc::FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: photocraft_color::Color::WHITE,
shadow: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.75),
@@ -3322,6 +3325,56 @@ mod tests {
assert!(diff > 5.0, "glow noise changes the coverage: {diff}");
}
/// Issue #7833: the speckle stays inside the glow's visible falloff instead of filling the
/// blur's whole rectangular reach out to the map edge.
#[test]
fn effect_noise_stays_inside_the_visible_falloff() {
let mut shape = Map::new(80, 80, 0.0);
for y in 35..45 {
for x in 35..45 {
shape.v[y * 80 + x] = 1.0;
}
}
let g = |noise: f32| Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 10.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise,
source: GlowSource::Edge,
};
let s = |noise: f32| Shadow {
common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0),
color: photocraft_color::Color::BLACK,
angle: 0.0,
use_global_light: false,
distance: 0.0,
spread: 0.0,
size: 10.0,
contour: Contour::Linear,
anti_alias: false,
noise,
knocks_out: true,
};
let light = GlobalLight::default();
let pairs = [
("glow", glow_map(&shape, &g(0.0), false, (0, 0)), glow_map(&shape, &g(0.5), false, (0, 0))),
("shadow", shadow_map(&shape, &s(0.0), &light, false, (0, 0)), shadow_map(&shape, &s(0.5), &light, false, (0, 0))),
];
for (name, plain, noisy) in pairs {
for (i, (p, n)) in plain.v.iter().zip(&noisy.v).enumerate() {
if *p < 1.0 / 255.0 {
assert!(*n <= 2.0 / 255.0, "{name}: pixel ({}, {}) invisible without noise: {p} -> {n}", i % 80, i / 80);
}
}
}
}
#[test]
fn shifting_by_hostile_distances_does_not_overflow() {
let m = square_shape(12);
+118 -8
View File
@@ -689,11 +689,33 @@ fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option<R
});
debug_assert!(ok.is_ok());
}
for (i, p) in acc.iter_mut().enumerate() {
let ((x0, x1), (y0, y1)) = (span(ox0 + i % ow, fw, w), span(oy0 + i / ow, fh, h));
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
let a = p[3];
*p = if a > 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] };
let col_w: Vec<usize> = (0..ow)
.map(|x| {
let (x0, x1) = span(ox0 + x, fw, w);
(x1 - x0).max(1)
})
.collect();
let norm_row = |row: usize, out: &mut [[f32; 4]]| {
let (y0, y1) = span(oy0 + row, fh, h);
let rh = (y1 - y0).max(1);
for (p, &cw) in out.iter_mut().zip(&col_w) {
let a = p[3];
if a > 0.0 {
let n = (rh * cw) as f32;
*p = [p[0] / a, p[1] / a, p[2] / a, a / n];
} else {
*p = [0.0; 4];
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
acc.par_chunks_mut(ow).enumerate().for_each(|(row, out)| norm_row(row, out));
}
#[cfg(target_arch = "wasm32")]
for (row, out) in acc.chunks_mut(ow).enumerate() {
norm_row(row, out);
}
Buffer { rect: out, px: acc }
}
@@ -1144,9 +1166,7 @@ pub fn relayout_damage(a: &Document, b: &Document) -> Rect {
}
/// The bounding box of a shape layer's path and stroke, ignoring its Density and Feather: its
/// path control bounds plus stroke width. It is the frame Free Transform and the Move tool's
/// transform controls show for a soft shape, whose rendering reaches past it (in Photoshop a
/// shape's transform frame follows its path whatever its mask Density and Feather).
/// path control bounds plus stroke width (a conservative reach for any alignment, cap and join).
pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) {
return None;
@@ -1157,6 +1177,75 @@ pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
(!r.is_empty()).then_some(r)
}
/// The frame Free Transform, the Move tool's transform controls, snapping and Properties show for
/// a soft shape (Density below 100 % or a Feather), whose rendering reaches past its path: the
/// pixels the same shape renders in `format` over `canvas` at full Density without Feather, so
/// the frame is exactly the hard shape's whatever the stroke's alignment, cap and join (in
/// Photoshop a shape's transform frame follows its path and stroke whatever its mask Density and
/// Feather, #5846). An inverted path's frame is its own outline, not the whole canvas. Renders
/// only the strips along the edges of the rasterizers' (1 px conservative) bounds.
pub fn soft_shape_frame(sh: &photocraft_doc::ShapeLayer, format: photocraft_color::PixelFormat, canvas: Rect) -> Option<Rect> {
let mut path = sh.path.clone();
path.inverted = false;
let hard = photocraft_doc::ShapeLayer { density: 1.0, feather: 0.0, path, fill: sh.fill.clone(), stroke: sh.stroke.clone(), ..Default::default() };
let shape = photocraft_vector::CompiledShape::new(&hard, photocraft_vector::DEFAULT_TOLERANCE, canvas);
let r = shape.bounds()?.intersect(&canvas);
trim_to_painted(r, |strip| shape.render(format, strip).content_bounds())
}
/// `r` shrunk to the pixels `painted` reports (the painted bounds within a strip of `r`, empty if
/// none), looking in from each edge through strips that double in thickness: a few renders per
/// edge however wide the empty margin (a subtracted or intersected subpath can leave most of the
/// rasterizers' bounds empty), and none of `r`'s inside rendered at once.
fn trim_to_painted(mut r: Rect, mut painted: impl FnMut(Rect) -> Rect) -> Option<Rect> {
// Top: the first strip holding paint gives the first painted row.
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x0, r.y0, r.x1, r.y0.saturating_add(t).min(r.y1));
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.y0 = p.y0;
break;
}
r.y0 = strip.y1;
t = t.saturating_mul(2);
}
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x0, r.y1.saturating_sub(t).max(r.y0), r.x1, r.y1);
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.y1 = p.y1;
break;
}
r.y1 = strip.y0;
t = t.saturating_mul(2);
}
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x0, r.y0, r.x0.saturating_add(t).min(r.x1), r.y1);
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.x0 = p.x0;
break;
}
r.x0 = strip.x1;
t = t.saturating_mul(2);
}
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x1.saturating_sub(t).max(r.x0), r.y0, r.x1, r.y1);
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.x1 = p.x1;
break;
}
r.x1 = strip.x0;
t = t.saturating_mul(2);
}
(!r.is_empty()).then_some(r)
}
/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width
/// (and the reach of a soft fill).
pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
@@ -1840,6 +1929,27 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
return;
}
if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_))
&& let Some(s) = layer.surface()
&& layer.blend == BlendMode::Normal
&& opacity >= 1.0
&& !clipped.iter().any(|c| c.visible)
&& layer.mask.is_none()
&& layer.vector_mask.is_none()
&& text_gamma(layer) == 1.0
&& !psblend::LAB_MIX.with(|l| l.get())
&& backdrop.px.iter().all(|p| p[3] <= 0.0)
{
s.read_rgba_into(rect, &mut backdrop.px);
for p in &mut backdrop.px {
if p[3] <= 0.0 {
*p = [0.0; 4];
} else if p[3] < 1.0 {
*p = psblend::composite_normal_rgb([0.0; 4], *p, 1.0);
}
}
return;
}
if clipped.is_empty()
&& let Some(surf) = pixel_surface(layer)
{
+42 -2
View File
@@ -58,8 +58,9 @@ fn feather_matches_photoshops_ramp() {
let r = row(&shape_doc(depth, 1.0, 10.0, None), 100);
let ours: Vec<i32> = (0..15).map(|i| r[30 + 3 * i]).collect();
let dev = ours.iter().zip(PS_FEATHER_10).map(|(a, b)| (a - i32::from(b)).abs()).max().unwrap_or(0);
// Same Gaussian (sigma = feather) as a vector mask: within 3 levels of Photoshop.
assert!(dev <= 3, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})");
// Same Gaussian (sigma = feather) as a vector mask: within 1 level of Photoshop (as
// scorecard TOOL-214-12 claims; measured max deviation 1 at every depth).
assert!(dev <= 1, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})");
eprintln!("{depth:?}: ours {ours:?}, Photoshop {PS_FEATHER_10:?}, max deviation {dev}");
// The path runs between pixels 49 and 50: their mean is half way.
assert!((r[49] + r[50] - 255).abs() <= 2, "{depth:?}: half way on the path: {} {}", r[49], r[50]);
@@ -157,3 +158,42 @@ fn soft_shape_bounds_cover_the_fill() {
assert_eq!(layer_bounds(&dense.layers[1], canvas), canvas);
assert!(effect_outline(&dense.layers[1]).is_none(), "a soft fill's alpha is its outline");
}
/// A soft shape's frame is the hard shape's painted pixels, also where a subtracted subpath
/// leaves most of the rasterizers' bounds empty (#5846), at every depth.
#[test]
fn soft_shape_frame_skips_a_subtracted_margin() {
let canvas = Rect::new(0, 0, 200, 200);
let mut cut = Subpath::polygon(&[(40.0, 40.0), (160.0, 40.0), (160.0, 140.0), (40.0, 140.0)]);
cut.op = photocraft_doc::vector::PathOp::Subtract;
let path = Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)]), cut]);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let sh = ShapeLayer { path: path.clone(), fill: Some(Fill::Solid(Color::BLACK)), density: 0.5, feather: 4.0, ..ShapeLayer::default() };
assert_eq!(soft_shape_frame(&sh, doc(depth).pixel_format(), canvas), Some(Rect::new(50, 140, 150, 150)), "{depth:?}");
}
}
/// The frame scan renders a few strips per edge, not one per empty row or column: a 2-px
/// painted spot in a 4000 × 3000 bounds takes a handful of renders and is found exactly.
#[test]
fn soft_shape_frame_scan_is_bounded() {
let spot = Rect::new(2500, 1700, 2502, 1702);
let mut calls = 0;
let got = trim_to_painted(Rect::new(0, 0, 4000, 3000), |strip| {
calls += 1;
strip.intersect(&spot)
});
assert_eq!(got, Some(spot));
assert!(calls <= 4 * 14, "{calls} renders");
let mut calls = 0;
assert_eq!(
trim_to_painted(Rect::new(0, 0, 4000, 3000), |_| {
calls += 1;
Rect::EMPTY
}),
None
);
assert!(calls <= 14, "an empty shape stops after the top edge: {calls} renders");
assert_eq!(trim_to_painted(Rect::EMPTY, |r| r), None);
assert_eq!(trim_to_painted(Rect::new(3, 4, 9, 7), |r| r), Some(Rect::new(3, 4, 9, 7)), "a full rect keeps its edges");
}
+1 -1
View File
@@ -21,7 +21,7 @@ fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
}
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint })
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint, overprint: false })
}
fn blue() -> FxPaint {
+7
View File
@@ -1172,6 +1172,7 @@ fn stroke(size: f32, position: StrokePosition) -> Effect {
size,
position,
paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)),
overprint: false,
})
}
@@ -1448,6 +1449,7 @@ fn satin_and_bevel_stay_inside_shape() {
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
@@ -1624,6 +1626,7 @@ fn bevel_fx(style: photocraft_doc::BevelStyle, soften: f32) -> Effect {
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
@@ -1864,6 +1867,7 @@ fn emboss_shades_the_composited_layer() {
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
@@ -1913,6 +1917,7 @@ fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() {
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
overprint: false,
});
let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]);
assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20));
@@ -2843,6 +2848,7 @@ fn nested_parallel_fx_doc() -> Document {
size: 3.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})];
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
glow.opacity = 0.5;
@@ -2873,6 +2879,7 @@ fn nested_parallel_fx_doc() -> Document {
altitude: 32.0,
use_global_light: false,
gloss_contour: Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
+6
View File
@@ -166,6 +166,9 @@ pub struct StrokeFx {
pub size: f32,
pub position: StrokePosition,
pub paint: FxPaint,
/// Overprint (PSD `overprint`).
#[serde(default)]
pub overprint: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -213,6 +216,9 @@ pub struct Bevel {
pub altitude: f32,
pub use_global_light: bool,
pub gloss_contour: Contour,
/// Gloss Contour › Anti-aliased (PSD `antialiasGloss`).
#[serde(default)]
pub gloss_anti_alias: bool,
pub highlight: FxCommon,
pub highlight_color: Color,
pub shadow: FxCommon,
+51 -4
View File
@@ -98,6 +98,10 @@ pub struct ActionState {
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
#[serde(skip)]
pub rev: u64,
/// Bumped by each `actions.record`, so a background job knows whether the recording it was
/// started in is still the one running when its result lands.
#[serde(skip)]
pub take: u64,
}
impl ActionState {
@@ -121,11 +125,22 @@ pub fn replayable(id: &str) -> bool {
&& (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal)))
}
/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path
/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder
/// instead.
/// File › Save, Save As and Save a Copy, and File › Export › Export As, recorded by the desktop
/// shell once the file has a path (#2032, #4373, #7207). The shell replays them; batch and
/// droplets write to their destination folder instead.
pub fn shell_save_command(id: &str) -> bool {
matches!(id, "file.save" | "file.saveAs" | "file.saveACopy")
matches!(id, "file.save" | "file.saveAs" | "file.saveACopy" | "file.export.exportAs")
}
/// The failure for a played Save step whose document `target` (the one active when the step
/// started) is no longer open: a Save Document script event closed it. Playback stops there, so
/// the next step without a document, such as the Close half of an imported Close with Save: Yes,
/// doesn't act on whichever document is active now (#7367).
pub fn save_target_closed(s: &Session, id: &str, target: Option<photocraft_doc::DocId>) -> Option<String> {
let target = target.filter(|_| shell_save_command(id))?;
(!s.documents().iter().any(|d| d.doc.id == target)).then(|| {
"the document was closed during the save (by a Save Document script event); playback stopped so later steps don't act on another document".into()
})
}
/// View-only menu commands recorded by the desktop shell. Headless playback reports
@@ -287,6 +302,7 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
s.actions.list.len() - 1
};
s.actions.recording = Some((s.journal.len(), idx));
s.actions.take = s.actions.take.wrapping_add(1);
let name = s.actions.list.get(idx).map(|a| a.name.clone()).unwrap_or_default();
Ok(json!({"action": name, "index": idx}))
}
@@ -322,6 +338,12 @@ fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"action": action.name, "steps": action.steps.len()}))
}
/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an
/// open document and nothing selected (actions often start with it to clear any selection, #6744).
fn noop_step(s: &Session, id: &str) -> bool {
id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none())
}
fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<Value>) {
let mut ran = 0u64;
let mut failed = None;
@@ -332,6 +354,10 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
break;
}
if noop_step(s, id) {
ran += 1;
continue;
}
match s.execute(id, params.clone()) {
Ok(value) => {
if let Some(error) = nested_failure(id, &value) {
@@ -722,6 +748,27 @@ mod tests {
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
}
#[test]
fn deselect_with_nothing_selected_is_a_noop_in_playback() {
// #6744: imported actions often start with Deselect; with no selection Photoshop plays on.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let before = s.active().unwrap().history.past_len();
// Outside playback the command stays disabled, like Photoshop's greyed menu item.
assert!(s.execute("select.deselect", json!({})).is_err());
assert!(s.active().unwrap().doc.selection.is_none());
assert_eq!(s.active().unwrap().history.past_len(), before, "rejection adds no history step");
let deselect = crate::commands::find("select.deselect").unwrap();
assert_eq!((deselect.run)(&mut s, &json!({})).unwrap(), Value::Null);
assert_eq!(s.active().unwrap().history.past_len(), before, "no history step");
let steps = vec![("select.deselect".to_string(), json!({})), ("layer.new.layer".to_string(), json!({"name": "A"}))];
s.actions.list.push(Action::new("Clear", steps));
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(played["ran"], 2, "{played}");
assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "A");
}
#[test]
fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
let mut s = Session::new();
+24
View File
@@ -261,6 +261,8 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
let label = "Auto-Blend Layers";
let (new_layer, depth_layer) = s.edit(label, |doc, active| {
for (id, wt) in ids.iter().zip(&weights) {
// The Background can't have a mask: it becomes a normal layer first, as in Photoshop.
crate::extra_cmds::background_to_layer_for_mask(doc, *id);
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
let mut mask = LayerMask::hide_all();
mask.surface.write_region(area, wt);
@@ -452,6 +454,28 @@ mod tests {
}
}
/// #5745: the Background can't take a mask, so Auto-Blend turns it into a normal layer first.
#[test]
fn auto_blend_turns_the_background_into_a_layer() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 60, "background": "white"})).unwrap();
let bg = s.active().unwrap().doc.background().unwrap().id;
s.execute("layer.new.layer", json!({"name": "right"})).unwrap();
s.edit("r", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(70, 0, 200, 60), &[0.9, 0.6, 0.2, 1.0]);
Ok(())
})
.unwrap();
let right = s.active().unwrap().active_layer.unwrap();
select(&mut s, &[bg, right]);
s.execute("edit.autoBlendLayers", json!({"method": "panorama"})).unwrap();
let doc = &s.active().unwrap().doc;
assert!(!doc.is_background(bg), "the Background became a layer to take its mask");
assert!(doc.layer(bg).unwrap().mask.is_some());
s.undo();
assert!(s.active().unwrap().doc.is_background(bg), "undo restores the Background");
}
#[test]
fn auto_blend_stack_without_seamless() {
let mut s = Session::new();
+54 -2
View File
@@ -5,6 +5,7 @@ use crate::{EngineError, Result};
#[cfg(test)]
thread_local! {
static FAIL_NEXT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static FAIL_AFTER: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
/// Reserve and initialize a buffer without invoking the allocator's abort-on-OOM path.
@@ -18,7 +19,7 @@ pub(crate) fn filled<T: Clone>(len: usize, value: T, purpose: &str) -> Result<Ve
pub(crate) fn capacity<T>(len: usize, purpose: &str) -> Result<Vec<T>> {
let bytes = len.checked_mul(std::mem::size_of::<T>()).ok_or_else(|| error(purpose, "requested size overflowed"))?;
#[cfg(test)]
if FAIL_NEXT.with(|flag| flag.replace(false)) {
if failpoint_hits() {
return Err(error(purpose, "not enough memory"));
}
let mut out = Vec::new();
@@ -30,7 +31,7 @@ pub(crate) fn capacity<T>(len: usize, purpose: &str) -> Result<Vec<T>> {
/// The test-only failpoint lets command tests prove their transaction boundary.
pub(crate) fn checkpoint(purpose: &str) -> Result<()> {
#[cfg(test)]
if FAIL_NEXT.with(|flag| flag.replace(false)) {
if failpoint_hits() {
return Err(error(purpose, "not enough memory"));
}
let _ = purpose;
@@ -57,6 +58,34 @@ pub(crate) fn fail_next_for_test() {
FAIL_NEXT.with(|flag| flag.set(true));
}
/// Fail the fallible allocation or checkpoint after `skip` clean attempts, then stop.
/// `fail_after_for_test(0)` is equivalent to [`fail_next_for_test`].
#[cfg(test)]
pub(crate) fn fail_after_for_test(skip: usize) {
FAIL_AFTER.with(|flag| flag.set(Some(skip)));
}
/// Whether a test failpoint trips here: the single-shot flag, or the counted flag
/// once its `skip` attempts have passed cleanly earlier. The shot does not advance the
/// counted flag: a one-shot failure is not itself a clean attempt.
#[cfg(test)]
fn failpoint_hits() -> bool {
if FAIL_NEXT.with(|flag| flag.replace(false)) {
return true;
}
FAIL_AFTER.with(|flag| match flag.get() {
Some(0) => {
flag.set(None);
true
}
Some(rest) => {
flag.set(Some(rest - 1));
false
}
None => false,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -67,4 +96,27 @@ mod tests {
let result = filled::<u8>(16, 0, "test image");
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for test image")));
}
#[test]
fn fail_after_trips_only_on_the_counted_attempt() {
fail_after_for_test(2);
assert!(filled::<u8>(4, 0, "one").is_ok(), "the counted failpoint lets the first attempts pass cleanly");
assert!(filled::<u8>(4, 0, "two").is_ok());
assert!(filled::<u8>(4, 0, "three").is_err_and(|e| e.to_string().contains("not enough memory for three")));
// The flag is consumed by the trip: later attempts are clean again.
assert!(filled::<u8>(4, 0, "four").is_ok());
}
#[test]
fn the_one_shot_does_not_spend_the_counted_failpoint() {
// One-shot and counted flags both armed: the shot trips the first attempt, and that
// failure is not a clean attempt, so the counter still has one to go past (review note
// on #7353). Afterwards the counted attempt trips as usual.
fail_after_for_test(1);
fail_next_for_test();
assert!(filled::<u8>(4, 0, "shot").is_err_and(|e| e.to_string().contains("not enough memory for shot")));
assert!(filled::<u8>(4, 0, "skipped").is_ok(), "the counted attempt has not run out of clean attempts");
assert!(filled::<u8>(4, 0, "counted").is_err_and(|e| e.to_string().contains("not enough memory for counted")));
assert!(filled::<u8>(4, 0, "done").is_ok());
}
}
+2 -1
View File
@@ -426,6 +426,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
};
let grown = |len: u32, by: i32| (i64::from(len) + i64::from(by)).clamp(1, 300_000) as u32;
let label = if duplicate { "Duplicate Artboard" } else { "New Artboard" };
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (nid, rect) = s.edit(label, |doc, active| {
if (sx, sy) != (0, 0) {
crate::image_cmds::translate_doc(doc, cmd, sx, sy)?;
@@ -440,7 +441,7 @@ fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
let nid = if duplicate {
let src_rect = doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or(EngineError::NoLayer(id))?;
let (dx, dy) = (rect.x0.saturating_sub(src_rect.x0), rect.y0.saturating_sub(src_rect.y0));
let copy = crate::commands::layer_copy(doc, id)?;
let copy = crate::commands::layer_copy(doc, id, add_copy)?;
let nid = doc.insert_above(Some(id), copy);
// Grow the canvas first, so shapes on the board re-render uncut where they land.
let fit = |len: u32, edge: i32| i64::from(len).max(i64::from(edge)).clamp(1, 300_000) as u32;
+21
View File
@@ -612,3 +612,24 @@ fn add_beside_places_a_board_on_each_side_and_alt_duplicates_contents() {
assert_eq!(doc(&s).size, before);
assert!(s.execute("layer.artboard.addBeside", json!({"side": "diagonal"})).is_err());
}
/// Adding a duplicated artboard beside another appends "copy" to its name only while the Layers panel option is on.
#[test]
fn add_beside_duplicate_honors_panel_copy_name_preference() {
for add_copy in [false, true] {
let mut s = session(8);
let id = board_with_square(&mut s);
let name = doc(&s).layer(id).unwrap().name.clone();
s.edit_prefs(|prefs| {
prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"addCopyToCopiedLayersAndGroups": add_copy}));
});
let before = doc(&s).clone();
let result = s.execute("layer.artboard.addBeside", json!({"layer": id.0, "side": "bottom", "duplicate": true})).unwrap();
let copy = LayerId(result["layer"].as_u64().unwrap());
let expected = if add_copy { before.copy_name(&name) } else { name };
assert_eq!(doc(&s).layer(copy).unwrap().name, expected);
assert!(red_at(&s, 15, 155), "the copied artboard retains its contents");
s.undo();
assert_eq!(doc(&s), &before);
}
}
+450 -80
View File
@@ -121,6 +121,7 @@ enum Target {
Property(String),
Class,
Offset(i32),
Index(u32),
Other,
}
@@ -131,6 +132,7 @@ fn target(r: &[ReferenceItem]) -> Target {
Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)),
Some(ReferenceItem::Class(_)) => Target::Class,
Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset),
Some(ReferenceItem::Index { index, .. }) => Target::Index(*index),
_ => Target::Other,
}
}
@@ -389,6 +391,8 @@ enum Kind {
Pct,
Ang,
Long,
/// An integer field whose value must survive conversion without rounding or saturation.
ExactLong,
Dbl,
Bool,
/// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs.
@@ -402,11 +406,13 @@ enum Kind {
/// One descriptor key ↔ one command param.
struct F(&'static str, &'static str, Kind);
/// Import mapped descriptor fields, leaving absent values to command defaults.
/// Return `None` when a present enum, blend mode or colour cannot be represented.
fn fields_from(d: &Descriptor, fields: &[F]) -> Option<Map<String, Value>> {
let mut m = Map::new();
for F(key, param, kind) in fields {
let v = match kind {
Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from),
Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::ExactLong | Kind::Dbl => get_num(d, key).map(Value::from),
Kind::Bool => get_bool(d, key).map(Value::from),
Kind::Enum(_, table) => match get_enum(d, key) {
Some(e) => Some(Value::from(lookup(table, &e)?)),
@@ -428,6 +434,8 @@ fn fields_from(d: &Descriptor, fields: &[F]) -> Option<Map<String, Value>> {
Some(m)
}
/// Export supplied mapped params, omitting absent values to preserve event defaults.
/// Return `None` for unsupported values; `ExactLong` also rejects rounding or saturation.
fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option<Descriptor> {
for F(key, param, kind) in fields {
let Some(v) = p.get(param) else { continue };
@@ -436,6 +444,14 @@ fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option<Descriptor> {
Kind::Pct => pct(v.as_f64()?),
Kind::Ang => ang(v.as_f64()?),
Kind::Long => long(v.as_f64()?),
Kind::ExactLong => {
let number = v.as_f64()?;
let integer = number as i32;
if f64::from(integer) != number {
return None;
}
D::Integer(integer)
}
Kind::Dbl => D::Double(v.as_f64()?),
Kind::Bool => D::Boolean(v.as_bool()?),
Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?),
@@ -612,19 +628,22 @@ fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option<Descriptor> {
Some(e)
}
// Exporters leave out a param the step doesn't set only where Photoshop plays the missing key
// as PhotoCraft's default (checked against Photoshop: Fill opacity and mode, Crop delete,
// Selective Color method, Channel Mixer monochrome, Paint Bucket contiguous; Gradient Map
// reverse and dither, which Photoshop itself leaves out when off). Elsewhere they
// write PhotoCraft's default, as Photoshop plays a missing key differently (Color Balance
// preserve luminosity off, Canvas Size anchored top left, Apply Image Normal) or refuses the
// step (Paint Bucket without a tolerance).
fn hue_sat_to(p: &Value) -> Option<Descriptor> {
let d = desc("HStr").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false)));
let mut entries = vec![D::Descriptor(hue_entry(p, None)?)];
for (i, name) in HUE_RANGES.iter().enumerate() {
if let Some(r) = p.get(*name).filter(|v| v.is_object()) {
entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?));
}
}
Some(
desc("HStr")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false)))
.with("Adjs", D::List(entries)),
)
Some(d.with("Adjs", D::List(entries)))
}
fn triple(d: &Descriptor, k: &str) -> Option<Value> {
@@ -694,12 +713,17 @@ fn channel_mixer_to(p: &Value) -> Option<Descriptor> {
d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3)))));
}
}
Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false))))
if let Some(m) = jbool(p, "monochrome") {
d = d.with("Mnch", D::Boolean(m));
}
Some(d)
}
/// Photoshop's `Clrs` colour-range enumeration. Yellows is `Ylws` (`Yllw` is the yellow ink
/// key); earlier PhotoCraft builds wrote `Yllw`, which import still reads (#6745).
const SELECTIVE_RANGES: &[(&str, &str)] = &[
("Rds", "reds"),
("Yllw", "yellows"),
("Ylws", "yellows"),
("Grns", "greens"),
("Cyns", "cyans"),
("Bls", "blues"),
@@ -716,7 +740,8 @@ fn selective_from(d: &Descriptor) -> Option<Value> {
}
for e in get_list(d, "ClrC").unwrap_or(&[]) {
let D::Descriptor(e) = e else { return None };
let name = lookup(SELECTIVE_RANGES, &get_enum(e, "Clrs")?)?;
let range = get_enum(e, "Clrs")?;
let name = lookup(SELECTIVE_RANGES, if range == "Yllw" { "Ylws" } else { &range })?;
let v = |k: &str| get_num(e, k).unwrap_or(0.0);
out.insert(name.into(), json!([v("Cyn "), v("Mgnt"), v("Yllw"), v("Blck")]));
}
@@ -724,21 +749,46 @@ fn selective_from(d: &Descriptor) -> Option<Value> {
}
fn selective_to(p: &Value) -> Option<Descriptor> {
let mut list = Vec::new();
for (ps, name) in SELECTIVE_RANGES {
// Each range, as the command reads it (`adjust_cmds::selective_from_params`): the array form
// `"reds": [c, m, y, k]` first, then the scalar form, which names one range in `"colors"` and
// gives the channels it changes. Writing only the arrays dropped the scalar form's values, and
// the step exported as an adjustment that changes nothing (#7672).
let mut rows: Vec<Option<[f64; 4]>> = vec![None; SELECTIVE_RANGES.len()];
for (row, (_, name)) in rows.iter_mut().zip(SELECTIVE_RANGES) {
if let Some(a) = p.get(*name).and_then(Value::as_array) {
let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0);
*row = Some([v(0), v(1), v(2), v(3)]);
}
}
if let Some(named) = jstr(p, "colors") {
let i = SELECTIVE_RANGES.iter().position(|(_, name)| *name == named)?;
let row = rows.get_mut(i)?;
let mut v = row.unwrap_or([0.0; 4]);
for (slot, key) in v.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) {
// A channel that is given but is not a number is an error in the command
// (`adjust_cmds::selective_from_params`), so a step carrying one cannot be written
// either: ignoring it would export a different adjustment than the one recorded.
// `null` counts as absent there, and so here.
match p.get(key) {
None | Some(Value::Null) => {}
Some(_) => *slot = jnum(p, key)?,
}
}
*row = Some(v);
}
let mut list = Vec::new();
for (row, (ps, _)) in rows.iter().zip(SELECTIVE_RANGES) {
if let Some(v) = row {
list.push(D::Descriptor(
desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v(0))).with("Mgnt", pct(v(1))).with("Yllw", pct(v(2))).with("Blck", pct(v(3))),
desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v[0])).with("Mgnt", pct(v[1])).with("Yllw", pct(v[2])).with("Blck", pct(v[3])),
));
}
}
Some(
desc("SlcC")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" }))
.with("ClrC", D::List(list)),
)
let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom"));
if let Some(m) = jstr(p, "method") {
d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" }));
}
Some(d.with("ClrC", D::List(list)))
}
/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`.
@@ -787,12 +837,14 @@ fn gradient_map_to(p: &Value) -> Option<Descriptor> {
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(stops?))
.with("Trns", transparency);
Some(
desc("GdMp")
.with("Grad", D::Descriptor(grad))
.with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false)))
.with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))),
)
let mut d = desc("GdMp").with("Grad", D::Descriptor(grad));
if let Some(r) = jbool(p, "reverse") {
d = d.with("Rvrs", D::Boolean(r));
}
if let Some(dd) = jbool(p, "dither") {
d = d.with("Dthr", D::Boolean(dd));
}
Some(d)
}
/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), and
@@ -821,7 +873,7 @@ const ADJUSTMENTS: &[Adjustment] = &[
Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to },
adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]),
adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]),
adj_fields!("PhtF", "photoFilter", &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]),
Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to },
adj_fields!("BlnW", "blackWhite", BW),
Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to },
Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to },
@@ -832,6 +884,29 @@ fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> {
ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c.trim_end())
}
const PHOTO_FILTER_FIELDS: &[F] = &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)];
/// A `PhtF` step whose params carry a `filter` preset without an explicit `color`: the descriptor
/// form is colour-only (Photoshop stores no preset key — the `phfl` notes in `crates/io` conclude
/// the same from its CS2 oracles), so the resolved colour must be written, or re-imported playback
/// falls back to the default warming filter. With both present, the explicit colour wins, as when
/// running the command. An unknown preset id keeps the old export (no colour) instead of failing.
fn photo_filter_to(p: &Value) -> Option<Descriptor> {
let mut p = p.clone();
if p.get("color").is_none() {
let hex = p.get("filter").and_then(Value::as_str).and_then(preset_color);
if let (Some(hex), Some(o)) = (hex, p.as_object_mut()) {
o.insert("color".into(), hex.into());
}
}
fields_to(&p, PHOTO_FILTER_FIELDS, desc("PhtF"))
}
/// The preset id's resolved `#rrggbb`, or none (no id given, or an unknown one).
fn preset_color(id: &str) -> Option<String> {
crate::adjust_params::PHOTO_FILTERS.iter().find(|f| f.0 == id).map(|(_, _, rgb)| format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))
}
fn adjustment_by_suffix(suffix: &str) -> Option<&'static Adjustment> {
ADJUSTMENTS.iter().find(|a| a.suffix == suffix)
}
@@ -872,6 +947,11 @@ struct Event {
macro_rules! ev {
($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr) => {
ev!($code, $name, $label, $id, $fields, unless | _ | false)
};
// `unless`: params the descriptor can't carry, so such a step isn't exported (it would replay
// differently) rather than losing them.
($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr, unless $skip:expr) => {
Event {
code: $code,
name: $name,
@@ -881,11 +961,18 @@ macro_rules! ev {
Some(m) => step($id, Value::Object(m)),
None => Mapped::Unsupported { harmless: false },
},
to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None },
to: |_, p| if pixel_target(p) && !($skip)(p) { fields_to(p, $fields, desc("null")) } else { None },
}
};
}
/// Minimum / Maximum with Preserve: Roundness. The descriptor here maps the radius only, and
/// Photoshop's encoding of the shape isn't verified, so such a step is not exported: it would
/// replay as the default Squareness (#7233).
fn preserves_roundness(p: &Value) -> bool {
p.get("preserve").and_then(Value::as_str) == Some("roundness")
}
/// Whether `p` edits the layer's pixels: no `"target"` or `"target":"pixels"`. Photoshop's
/// descriptor for a plain event has no field for a mask, Quick Mask or channel target, so such
/// a step can't be exported faithfully.
@@ -992,6 +1079,14 @@ fn pattern_desc(p: &Value) -> Option<Descriptor> {
Some(t)
}
/// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command.
const MASK_USING: &[(&str, &str)] = &[
("RvlA", "layer.layerMask.revealAll"),
("HdAl", "layer.layerMask.hideAll"),
("RvlS", "layer.layerMask.revealSelection"),
("HdSl", "layer.layerMask.hideSelection"),
];
fn make_from(d: &Descriptor) -> Mapped {
// Duplicate layer/group into a new document: `Mk` with a document class, a layer
// `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN`
@@ -1013,13 +1108,25 @@ fn make_from(d: &Descriptor) -> Mapped {
}
return step("layer.duplicate", Value::Object(p));
}
// Add Layer Mask: a `Chnl` class made at the mask channel, `Usng` what it reveals.
if let Some(D::Class(c)) = d.get("Nw ")
&& trimmed(&c.class_id) == "Chnl"
{
// The target layer's mask only: a further item (a `Lyr` by name…) puts it on another layer.
let at_mask = get_ref(d, "At ")
.is_some_and(|r| r.len() == 1 && ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk"));
return match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) {
Some(id) if at_mask => none(id),
_ => Mapped::Unsupported { harmless: false },
};
}
if let Some(nw) = get_obj(d, "Nw ") {
// Layer via Copy / Cut (`Usng` = the selection), or a new document.
if get_enum(d, "Usng").as_deref() == Some("Slct") {
let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" };
let mut v = vec![(id, json!({}))];
if let Some(n) = get_text(nw, "Nm ") {
v.push(("layer.renameLayer", json!({"name": n})));
v.push(("layer.setProps", json!({"name": n})));
}
return steps(v);
}
@@ -1162,6 +1269,14 @@ fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
}
d
}
"layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection" => {
// Photoshop adds the mask to the target layer only.
if p.get("layer").is_some() {
return None;
}
let at = D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]);
desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?))
}
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
"file.new" => {
let mut n = desc("Dcmn");
@@ -1272,6 +1387,11 @@ fn set_from(d: &Descriptor) -> Mapped {
}
steps(v)
}
// The toolbox colours.
(Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) {
Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})),
None => unsupported,
},
(Some("Lyr"), _) => {
let Some(mut p) = layer_target(r) else { return unsupported };
let Some(t) = get_obj(d, "T ") else { return unsupported };
@@ -1313,7 +1433,15 @@ fn set_from(d: &Descriptor) -> Mapped {
(Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => {
match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) {
Some((a, t)) => match (a.from)(t) {
Some(p) => step("layer.setAdjustment", p),
// `kind` names the adjustment so Save Actions can write the class back.
// Not on an empty set (Photoshop's "reset to default"): params with any key
// merge, none reset.
Some(mut p) => {
if let Some(o) = p.as_object_mut().filter(|o| !o.is_empty()) {
o.insert("kind".into(), json!(a.suffix));
}
step("layer.setAdjustment", p)
}
None => unsupported,
},
None => unsupported,
@@ -1353,6 +1481,21 @@ fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
}
desc("null").with("null", quick_mask_ref())
}
"tools.setColors" => {
// One colour per Photoshop step: a step that sets both (or any other key) doesn't
// export rather than lose one.
if p.as_object().is_some_and(|o| o.keys().any(|k| k != "foreground" && k != "background")) {
return None;
}
let (key, hex) = match (jstr(p, "foreground"), jstr(p, "background")) {
(Some(f), None) => ("FrgC", f),
(None, Some(b)) => ("BckC", b),
_ => return None,
};
desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: code(key) }]))
.with("T ", D::Descriptor(color_desc(hex)?))
}
"select.rect" => {
let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?);
let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" };
@@ -1445,10 +1588,18 @@ fn select_from(d: &Descriptor) -> Mapped {
}
}
(Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true },
// A menu item chosen while recording (`Mn`): the View menu's zoom items replay.
(Some("Mn"), Target::Ordinal(item)) => match lookup(MENU_ITEMS, &item) {
Some(id) => none(id),
None => Mapped::Unsupported { harmless: false },
},
_ => Mapped::Unsupported { harmless: false },
}
}
/// `MnIt` menu-item codes ↔ the shell's view commands.
const MENU_ITEMS: &[(&str, &str)] = &[("FtOn", "view.fitOnScreen"), ("ActP", "view.actualPixels"), ("ZmIn", "view.zoomIn"), ("ZmOt", "view.zoomOut")];
fn select_to(id: &str, p: &Value) -> Option<Descriptor> {
let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]);
let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]);
@@ -1479,7 +1630,10 @@ fn select_to(id: &str, p: &Value) -> Option<Descriptor> {
"layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")),
"layer.selectTop" => desc("null").with("null", ord("Frnt")),
"layer.selectBottom" => desc("null").with("null", ord("Back")),
_ => return None,
_ => {
let item = lookup_back(MENU_ITEMS, id)?;
desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: code("MnIt"), value: code(item) }]))
}
})
}
@@ -1600,7 +1754,9 @@ fn close_to(_: &str, p: &Value) -> Option<Descriptor> {
/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything
/// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`.
fn transform_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
// No reference (a smart object replaced in place, `replaceLayer`) means the target layer.
let target_layer = [ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }];
let r: &[ReferenceItem] = get_ref(d, "null").unwrap_or(&target_layer);
let unsupported = Mapped::Unsupported { harmless: false };
let mut p = Map::new();
if let Some(o) = get_obj(d, "Ofst") {
@@ -1861,6 +2017,16 @@ fn visibility_to(_: &str, p: &Value) -> Option<Descriptor> {
fn move_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
// The selection outline nudged.
if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Property(p) if p == "fsel") {
return match get_obj(d, "T ") {
Some(t) if matches!(t.get("Hrzn"), Some(D::UnitFloat { unit: PX, .. })) => match (get_num(t, "Hrzn"), get_num(t, "Vrtc")) {
(Some(dx), Some(dy)) => step("select.transformSelection", json!({"dx": dx.round(), "dy": dy.round()})),
_ => Mapped::Unsupported { harmless: false },
},
_ => Mapped::Unsupported { harmless: false },
};
}
if ref_class(r).as_deref() != Some("Lyr") {
return Mapped::Unsupported { harmless: false };
}
@@ -1896,6 +2062,8 @@ fn move_from(d: &Descriptor) -> Mapped {
Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward",
Target::Offset(1) => "layer.arrange.bringForward",
Target::Offset(-1) => "layer.arrange.sendBackward",
// Index 0 is the bottom of the stack.
Target::Index(0) => "layer.arrange.sendToBack",
_ => return Mapped::Unsupported { harmless: false },
};
step(id, Value::Object(p))
@@ -2071,7 +2239,9 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
if let Some(o) = jnum(p, "opacity") {
d = d.with("Opct", pct(o));
}
d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true)));
if let Some(c) = jbool(p, "contiguous") {
d = d.with("Cntg", D::Boolean(c));
}
if let Some(m) = jbool(p, "sampleAllLayers") {
d = d.with("Mrgd", D::Boolean(m));
}
@@ -2082,7 +2252,12 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
if contents == "color" {
d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?));
}
d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?));
if let Some(o) = jnum(p, "opacity") {
d = d.with("Opct", pct(o));
}
if let Some(m) = jstr(p, "mode") {
d = d.with("Md ", enm("BlnM", blend_ps(m)?));
}
if let Some(t) = jbool(p, "preserveTransparency") {
d = d.with("PrsT", D::Boolean(t));
}
@@ -2112,6 +2287,16 @@ fn bucket_color(v: Option<&Value>) -> Option<Option<Descriptor>> {
Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0))))
}
/// Preserve the command's effective integer width instead of rounding its input again.
fn stroke_to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) {
return None;
}
let mut params = if p.is_null() { Map::new() } else { p.as_object()?.clone() };
params.insert("width".into(), Value::from(crate::extra_cmds::stroke_width(p)));
fields_to(&Value::Object(params), STROKE, desc("null"))
}
const STROKE: &[F] = &[
F("Wdth", "width", Kind::Long),
F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)),
@@ -2496,7 +2681,10 @@ fn crop_to(_: &str, p: &Value) -> Option<Descriptor> {
if let Some(a) = jnum(p, "angle") {
d = d.with("Angl", ang(a));
}
Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true))))
if let Some(del) = jbool(p, "deleteCroppedPixels") {
d = d.with("Dlt ", D::Boolean(del));
}
Some(d)
}
fn stop_from(d: &Descriptor) -> Mapped {
@@ -2611,14 +2799,18 @@ fn levels_event_to(id: &str, p: &Value) -> Option<Descriptor> {
})
}
/// A direct adjustment event (Image › Adjustments › …).
/// A direct adjustment event (Image › Adjustments › …). The command id is `image.adjustments.<name>`
/// unless it is given.
macro_rules! adjustment_event {
($code:literal, $class:literal, $name:literal, $label:literal) => {
adjustment_event!($code, $class, $name, $label, concat!("image.adjustments.", $name))
};
($code:literal, $class:literal, $name:literal, $label:literal, $id:expr) => {
Event {
code: $code,
name: $name,
label: $label,
ids: &[concat!("image.adjustments.", $name)],
ids: &[$id],
from: |d| match adjustment_by_class($class) {
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
None => Mapped::Unsupported { harmless: false },
@@ -2670,6 +2862,148 @@ fn gallery_to(id: &str, p: &Value) -> Option<Descriptor> {
fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code)))
}
/// Select and Mask as Photoshop CC 2015.5 and later record it (`smartBrushWorkspace`); the
/// older Refine Edge event is [`REFINE_EDGE`], which is also what Select and Mask exports as.
const SMART_BRUSH: &[F] = &[
F("smartBrushRadius", "radius", Kind::Px),
F("smartBrushUseSmartRadius", "smartRadius", Kind::Bool),
F("smartBrushSmooth", "smooth", Kind::Long),
F("smartBrushFeather", "feather", Kind::Px),
F("smartBrushContrast", "contrast", Kind::Pct),
F("smartBrushShiftEdge", "shiftEdge", Kind::Pct),
F("smartBrushDecontaminate", "decontaminate", Kind::Bool),
F("smartBrushDeconAmount", "amount", Kind::Pct),
F("refineEdgeOutput", "output", Kind::Enum("refineEdgeOutput", REFINE_OUTPUT)),
F("sampleAllLayers", "sampleAllLayers", Kind::Bool),
];
/// Select › Color Range's `Clrs` presets ↔ `select.colorRange`'s `select`.
const COLOR_RANGE: &[(&str, &str)] = &[
("Rds", "reds"),
("Ylws", "yellows"),
("Grns", "greens"),
("Cyns", "cyans"),
("Bls", "blues"),
("Mgnt", "magentas"),
("Hghl", "highlights"),
("Mdtn", "midtones"),
("Shdw", "shadows"),
("OtOf", "outOfGamut"),
("skinTone", "skinTones"),
];
/// The Color Range keys the converter reads (or that don't change a preset's result:
/// `colorModel` is the model of sampled colours).
const COLOR_RANGE_KEYS: &[&str] = &[
"Clrs",
"Fzns",
"Invr",
"colorModel",
"shadowsFuzziness",
"shadowsUpperLimit",
"midtonesFuzziness",
"midtonesLowerLimit",
"midtonesUpperLimit",
"highlightsFuzziness",
"highlightsLowerLimit",
];
/// Color Range (`ClrR`) with a preset. Sampled Colors records Lab minimum and maximum colours,
/// which `select.colorRange` (eyedropper points on the image) can't reproduce, and an unknown
/// key (Detect Faces, Localized Color Clusters…) could change the result: both stay
/// unsupported.
fn color_range_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
if d.items.iter().any(|(k, _)| !COLOR_RANGE_KEYS.contains(&trimmed(k).as_str())) {
return unsupported;
}
let Some(preset) = get_enum(d, "Clrs").and_then(|c| lookup(COLOR_RANGE, &c)) else { return unsupported };
let mut p = Map::new();
p.insert("select".into(), preset.into());
let key = |suffix: &str| get_num(d, &format!("{preset}{suffix}"));
match preset {
"skinTones" => {
if let Some(f) = get_num(d, "Fzns") {
p.insert("fuzziness".into(), f.into());
}
}
"shadows" | "midtones" | "highlights" => {
if let Some(f) = key("Fuzziness") {
p.insert("fuzziness".into(), f.into());
}
let range = match (preset, key("LowerLimit"), key("UpperLimit")) {
("shadows", _, hi) => hi.map(Value::from),
("highlights", lo, _) => lo.map(Value::from),
(_, Some(lo), Some(hi)) => Some(json!([lo, hi])),
(_, None, None) => None,
_ => return unsupported,
};
if let Some(r) = range {
p.insert("tonalRange".into(), r);
}
}
_ => {}
}
if get_bool(d, "Invr") == Some(true) {
p.insert("invert".into(), true.into());
}
step("select.colorRange", Value::Object(p))
}
fn color_range_to(_: &str, p: &Value) -> Option<Descriptor> {
let preset = jstr(p, "select")?;
if p.as_object()?.keys().any(|k| !matches!(k.as_str(), "select" | "fuzziness" | "tonalRange" | "invert")) {
return None;
}
let mut d = desc("null").with("Clrs", enm("Clrs", lookup_back(COLOR_RANGE, preset)?));
let tonal = matches!(preset, "shadows" | "midtones" | "highlights");
if let Some(f) = jnum(p, "fuzziness") {
match preset {
"skinTones" => d = d.with("Fzns", long(f)),
_ if tonal => d = d.with(&format!("{preset}Fuzziness"), long(f)),
_ => {}
}
}
if let Some(r) = p.get("tonalRange") {
match preset {
"shadows" => d = d.with("shadowsUpperLimit", long(r.as_f64()?)),
"highlights" => d = d.with("highlightsLowerLimit", long(r.as_f64()?)),
"midtones" => {
let a = r.as_array()?;
d = d.with("midtonesLowerLimit", long(a.first()?.as_f64()?)).with("midtonesUpperLimit", long(a.get(1)?.as_f64()?));
}
_ => return None,
}
}
if jbool(p, "invert") == Some(true) {
d = d.with("Invr", D::Boolean(true));
}
Some(d)
}
/// Create / Release Clipping Mask (`groupEvent` / `ungroup`) on the target or a named layer.
fn clipping_from(d: &Descriptor, id: &str) -> Mapped {
match get_ref(d, "null") {
None => none(id),
Some(r) if ref_class(r).as_deref() == Some("Lyr") => match layer_target(r) {
Some(p) => step(id, Value::Object(p)),
None => Mapped::Unsupported { harmless: false },
},
Some(_) => Mapped::Unsupported { harmless: false },
}
}
fn clipping_to(_: &str, p: &Value) -> Option<Descriptor> {
Some(desc("null").with("null", layer_ref(p)))
}
/// A clipping mask event: `clipping_from` / `clipping_to` with one command id.
macro_rules! clipping_event {
($code:literal, $name:literal, $label:literal, $id:literal) => {
Event { code: $code, name: $name, label: $label, ids: &[$id], from: |d| clipping_from(d, $id), to: clipping_to }
};
}
/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label.
const EVENTS: &[Event] = &[
// Filters.
@@ -2696,6 +3030,14 @@ const EVENTS: &[Event] = &[
ev!("Mdn ", "median", "Median", "filter.noise.median", &[F("Rds ", "radius", Kind::Px)]),
ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]),
ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]),
// Photoshop records Emboss's angle, height and amount as plain integers (#7946).
ev!(
"Embs",
"emboss",
"Emboss",
"filter.stylize.emboss",
&[F("Angl", "angle", Kind::Long), F("Hght", "height", Kind::Long), F("Amnt", "amount", Kind::Long)]
),
ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]),
ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]),
ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]),
@@ -2724,8 +3066,8 @@ const EVENTS: &[Event] = &[
ev!("ShrM", "sharpenMore", "Sharpen More", "filter.sharpen.sharpenMore", &[]),
ev!("ShrE", "sharpenEdges", "Sharpen Edges", "filter.sharpen.sharpenEdges", &[]),
ev!("HghP", "highPass", "High Pass", "filter.other.highPass", &[F("Rds ", "radius", Kind::Px)]),
ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)]),
ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)]),
ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)], unless preserves_roundness),
ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)], unless preserves_roundness),
ev!(
"Ofst",
"offset",
@@ -2733,7 +3075,7 @@ const EVENTS: &[Event] = &[
"filter.other.offset",
&[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
),
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]),
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
// Adjustments (Image › Adjustments).
@@ -2755,17 +3097,7 @@ const EVENTS: &[Event] = &[
},
adjustment_event!("Crvs", "Crvs", "curves", "Curves"),
adjustment_event!("HStr", "HStr", "hueSaturation", "Hue/Saturation"),
Event {
code: "BrgC",
name: "brightnessEvent",
label: "Brightness/Contrast",
ids: &["image.adjustments.brightnessContrast"],
from: |d| match adjustment_by_class("BrgC") {
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
None => Mapped::Unsupported { harmless: false },
},
to: |_, p| adjustment_by_class("BrgC").and_then(|a| (a.to)(p)),
},
adjustment_event!("BrgC", "BrgC", "brightnessEvent", "Brightness/Contrast", "image.adjustments.brightnessContrast"),
adjustment_event!("Thrs", "Thrs", "threshold", "Threshold"),
adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"),
adjustment_event!("Invr", "Invr", "invert", "Invert"),
@@ -2773,29 +3105,9 @@ const EVENTS: &[Event] = &[
adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"),
adjustment_event!("Exps", "Exps", "exposure", "Exposure"),
adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"),
Event {
code: "",
name: "blackAndWhite",
label: "Black & White",
ids: &["image.adjustments.blackWhite"],
from: |d| match adjustment_by_class("BlnW") {
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
None => Mapped::Unsupported { harmless: false },
},
to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)),
},
adjustment_event!("", "BlnW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"),
adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"),
Event {
code: "GdMp",
name: "gradientMapEvent",
label: "Gradient Map",
ids: &["image.adjustments.gradientMap"],
from: |d| match adjustment_by_class("GdMp") {
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
None => Mapped::Unsupported { harmless: false },
},
to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)),
},
adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"),
adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"),
ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]),
ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]),
@@ -2895,6 +3207,10 @@ const EVENTS: &[Event] = &[
"layer.newAdjustmentLayer.channelMixer",
"layer.newAdjustmentLayer.selectiveColor",
"layer.newAdjustmentLayer.gradientMap",
"layer.layerMask.revealAll",
"layer.layerMask.hideAll",
"layer.layerMask.revealSelection",
"layer.layerMask.hideSelection",
"file.new",
"file.newFromClipboard",
],
@@ -2913,6 +3229,7 @@ const EVENTS: &[Event] = &[
"select.rect",
"select.lasso",
"select.editInQuickMaskMode",
"tools.setColors",
"layer.setProps",
"layer.renameLayer",
"layer.setLabelColor",
@@ -2938,6 +3255,10 @@ const EVENTS: &[Event] = &[
"channel.target",
"edit.undo",
"edit.redo",
"view.fitOnScreen",
"view.actualPixels",
"view.zoomIn",
"view.zoomOut",
],
from: select_from,
to: select_to,
@@ -2976,11 +3297,35 @@ const EVENTS: &[Event] = &[
to: merge_visible_to,
},
ev!("Mrg2", "mergeLayersNew", "Merge Layers", "layer.mergeLayers", &[]),
clipping_event!("Ungr", "ungroup", "Release Clipping Mask", "layer.releaseClippingMask"),
ev!("CpTL", "copyToLayer", "Layer Via Copy", "layer.new.layerViaCopy", &[]),
ev!("CtTL", "cutToLayer", "Layer Via Cut", "layer.new.layerViaCut", &[]),
ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]),
ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]),
ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]),
clipping_event!("GrpL", "groupEvent", "Create Clipping Mask", "layer.createClippingMask"),
ev!("", "ungroupLayersEvent", "Ungroup Layers", "layer.ungroupLayers", &[]),
ev!("", "copyEffects", "Copy Layer Style", "layer.layerStyle.copyLayerStyle", &[]),
ev!("", "pasteEffects", "Paste Layer Style", "layer.layerStyle.pasteLayerStyle", &[]),
Event {
code: "",
name: "collapseAllGroupsEvent",
label: "Collapse All Groups",
ids: &["layer.setExpanded"],
from: |_| step("layer.setExpanded", json!({"all": true, "expanded": false})),
to: |_, p| (jbool(p, "all") == Some(true) && jbool(p, "expanded") == Some(false)).then(|| desc("null")),
},
Event {
code: "",
name: "applyComp",
label: "Apply Layer Comp",
ids: &["layerComp.apply"],
from: |d| match get_ref(d, "null").map(target) {
Some(Target::Name(n)) => step("layerComp.apply", json!({"comp": n})),
_ => Mapped::Unsupported { harmless: false },
},
to: |_, p| Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))),
},
ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]),
Event {
code: "Algn",
@@ -3006,10 +3351,35 @@ const EVENTS: &[Event] = &[
ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]),
ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]),
ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]),
ev!("", "refineSelectionEdge", "Select and Mask", "select.refineEdge", REFINE_EDGE),
// `refineSelectionEdge` has no Sample All Layers, so a step with it exports as
// `smartBrushWorkspace` instead of dropping it.
Event {
code: "",
name: "refineSelectionEdge",
label: "Select and Mask",
ids: &["select.refineEdge"],
from: |d| match fields_from(d, REFINE_EDGE) {
Some(m) => step("select.refineEdge", Value::Object(m)),
None => Mapped::Unsupported { harmless: false },
},
to: |_, p| if p.get("sampleAllLayers").is_some() { None } else { fields_to(p, REFINE_EDGE, desc("null")) },
},
ev!("", "smartBrushWorkspace", "Select and Mask", "select.refineEdge", SMART_BRUSH),
ev!("", "autoCutout", "Select Subject", "select.subject", &[F("sampleAllLayers", "sampleAllLayers", Kind::Bool)]),
Event { code: "ClrR", name: "colorRange", label: "Color Range", ids: &["select.colorRange"], from: color_range_from, to: color_range_to },
// Edit.
Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to },
ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE),
Event {
code: "Strk",
name: "stroke",
label: "Stroke",
ids: &["edit.stroke"],
from: |d| match fields_from(d, STROKE) {
Some(m) => step("edit.stroke", Value::Object(m)),
None => Mapped::Unsupported { harmless: false },
},
to: stroke_to,
},
ev!("copy", "copyEvent", "Copy", "edit.copy", &[]),
ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]),
ev!("cut ", "cut", "Cut", "edit.cut", &[]),
@@ -3098,7 +3468,7 @@ pub struct Imported {
fn photoshop_step(item: &AtnItem) -> (String, Value) {
let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes()));
(PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor}))
(PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.label(), "descriptor": descriptor}))
}
/// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings
@@ -3133,7 +3503,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>)
let (new_steps, enabled) = match mapped {
Mapped::Steps(s) => (s, item.enabled),
Mapped::Unsupported { harmless } => {
unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() });
unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() });
(vec![photoshop_step(item)], item.enabled && !harmless)
}
};
@@ -3184,21 +3554,21 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
let event = jstr(params, "event")?;
let event = match event.as_bytes() {
[a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]),
_ => AtnEvent::Name(event.to_string()),
_ => AtnEvent::name(event),
};
let descriptor = match jstr(params, "descriptor") {
Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?),
None => None,
};
return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() });
return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").as_bytes().to_vec(), descriptor, ..Default::default() });
}
let mut params = params.clone();
if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() {
params["set"] = json!(set);
}
let (e, descriptor) = event_and_descriptor(id, &params)?;
let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() })
let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() })
}
/// An action's key as a shortcut string: `F5`, with Photoshop's Shift and Command options
+373 -4
View File
@@ -6,9 +6,9 @@ use serde_json::json;
fn item(event: &str, label: &str, d: Option<Descriptor>) -> AtnItem {
let event = match event.as_bytes() {
[a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]),
_ => AtnEvent::Name(event.into()),
_ => AtnEvent::name(event),
};
AtnItem { event, dictionary: label.into(), descriptor: d, ..Default::default() }
AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor: d, ..Default::default() }
}
fn b64(bytes: &[u8]) -> String {
@@ -199,6 +199,50 @@ fn save_round_trips_through_photoshop_events() {
assert_eq!(r["set"], "Ours");
}
/// `image.adjustments.selectiveColor` takes a range as an array (`"reds": [c, m, y, k]`) or as
/// the scalar form that names one range (`"colors": "reds", "cyan": 35`). Only the arrays were
/// written, so a step recorded the other way exported as an adjustment that changes nothing, with
/// `skipped` empty (#7672).
#[test]
fn a_selective_color_step_in_the_scalar_form_keeps_its_values() {
let back = |params: Value| -> Value {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), params)]));
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
assert_eq!(r["skipped"], json!([]));
let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
imported.actions[0].steps[0].1.clone()
};
// The numbers are read back as numbers; a percentage may arrive as 35 or 35.0.
let range =
|step: &Value, name: &str| -> Vec<f64> { step[name].as_array().unwrap_or(&Vec::new()).iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect() };
// The reported step: one range, one channel.
let scalar = back(json!({"method": "absolute", "colors": "reds", "cyan": 35}));
assert_eq!(scalar["method"], "absolute");
assert_eq!(range(&scalar, "reds"), [35.0, 0.0, 0.0, 0.0], "{scalar}");
// The scalar form over an array keeps both, as playing the step would.
let both = back(json!({"reds": [10, 20, 30, 40], "colors": "reds", "cyan": 35}));
assert_eq!(range(&both, "reds"), [35.0, 20.0, 30.0, 40.0], "{both}");
// The array form is unchanged.
let array = back(json!({"method": "relative", "yellows": [0, 0, -15, 5]}));
assert_eq!(range(&array, "yellows"), [0.0, 0.0, -15.0, 5.0], "{array}");
// A channel the command would refuse is not quietly dropped: the step is refused too, since
// writing it would export an adjustment that is not the one recorded. `null` is absent, as
// the command reads it.
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), json!({"colors": "reds", "cyan": "35"}))]));
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
assert!(e.contains("image.adjustments.selectiveColor"), "{e}");
let nulled = back(json!({"colors": "reds", "cyan": 35, "magenta": null}));
assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}");
}
#[test]
fn every_mapped_command_exports_and_imports_back() {
let cases: Vec<(&str, Value)> = vec![
@@ -206,6 +250,7 @@ fn every_mapped_command_exports_and_imports_back() {
("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})),
("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})),
("filter.noise.median", json!({"radius": 2})),
("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})),
("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})),
("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})),
("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})),
@@ -236,7 +281,20 @@ fn every_mapped_command_exports_and_imports_back() {
json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": true, "tintColor": "#e4d5b0"}),
),
("image.adjustments.channelMixer", json!({"red": [100, 0, 0, 0], "green": [0, 90, 10, 0], "blue": [0, 0, 100, 5], "monochrome": false})),
("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "neutrals": [0, 0, 0, 3]})),
("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "yellows": [0, 5, 0, 0], "neutrals": [0, 0, 0, 3]})),
("select.subject", json!({"sampleAllLayers": false})),
("select.colorRange", json!({"select": "midtones", "fuzziness": 30, "tonalRange": [90, 160], "invert": true})),
("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})),
("select.colorRange", json!({"select": "highlights", "tonalRange": 200})),
("select.colorRange", json!({"select": "reds"})),
("layer.createClippingMask", json!({})),
("layer.releaseClippingMask", json!({"layer": "Shadow"})),
("layer.layerMask.revealAll", json!({})),
("layer.layerMask.hideAll", json!({})),
("layer.layerMask.revealSelection", json!({})),
("layer.layerMask.hideSelection", json!({})),
("tools.setColors", json!({"foreground": "#336699"})),
("tools.setColors", json!({"background": "#ffffff"})),
("image.adjustments.desaturate", json!({})),
("image.autoTone", json!({})),
("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})),
@@ -337,6 +395,16 @@ fn every_mapped_command_exports_and_imports_back() {
("file.openAs", json!({"path": "/tmp/a.psd"})),
("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})),
("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})),
("tools.setColors", json!({"foreground": "#102030"})),
("tools.setColors", json!({"background": "#ffffff"})),
("view.fitOnScreen", json!({})),
("view.zoomIn", json!({})),
("layer.createClippingMask", json!({})),
("layer.ungroupLayers", json!({})),
("layer.layerStyle.copyLayerStyle", json!({})),
("layer.layerStyle.pasteLayerStyle", json!({})),
("layer.setExpanded", json!({"all": true, "expanded": false})),
("layerComp.apply", json!({"comp": "Original"})),
("actions.play", json!({"action": "Other", "set": "S"})),
(STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})),
// The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine).
@@ -437,6 +505,8 @@ fn every_mapped_command_exports_and_imports_back() {
"select.modify.smooth",
"select.reselect",
"select.similar",
"view.actualPixels",
"view.zoomOut",
"tools.swapColors",
"edit.cut",
"edit.copy",
@@ -465,7 +535,10 @@ fn every_mapped_command_exports_and_imports_back() {
// Every command the table names exists.
// (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.)
for id in EVENTS.iter().flat_map(|e| e.ids.iter()) {
assert!(crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id), "the .atn table names an unknown command `{id}`");
assert!(
crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id),
"the .atn table names an unknown command `{id}`"
);
}
}
@@ -518,6 +591,45 @@ fn bucket_keeps_an_explicit_color_through_atn() {
}
}
/// `image.adjustments.photoFilter` exported to .atn and imported back → its params, or None.
fn photo_filter_round_trip(params: Value) -> Option<Value> {
let item = export_item("image.adjustments.photoFilter", &params, "S")?;
let e = event_for(&item.event)?;
match (e.from)(item.descriptor.as_ref()?) {
Mapped::Steps(steps) => steps.first().map(|(id, p)| {
assert_eq!(id, "image.adjustments.photoFilter");
p.clone()
}),
_ => None,
}
}
#[test]
fn photo_filter_preset_only_step_keeps_its_resolved_color() {
// #7235: the Photoshop descriptor keeps only a colour (`phfl`/`PhtF` are colour-only; the io
// notes reach the same conclusion from the CS2 oracles). A step recorded with only `filter`
// previously exported with no colour at all, so re-imported playback fell back to the default
// warming filter; it must export the resolved colour of the chosen preset instead.
let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "density": 60, "preserveLuminosity": true})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#006dff", "density": 60, "preserveLuminosity": true}));
// An omitted density stays omitted: running the imported step applies the same default.
let mut back = photo_filter_round_trip(json!({"filter": "red"})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#ea1a1a"}));
// With an explicit colour too, the colour wins, as when running the command.
let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "color": "#123456", "density": 25})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#123456", "density": 25}));
let mut back = photo_filter_round_trip(json!({"color": "#123456", "density": 25})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#123456", "density": 25}));
// An unknown preset id keeps the old export: no colour resolves, playback applies its default.
let mut back = photo_filter_round_trip(json!({"filter": "nope", "density": 25})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"density": 25}));
}
#[test]
fn unsupported_shapes_become_photoshop_steps_not_errors() {
// Image size in two different percentages, a Paint Bucket click in percent of the canvas, a
@@ -565,6 +677,127 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
}
/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening,
/// highlight cleanup, high-pass sharpening), in the stringID form Photoshop wrote them.
#[test]
fn retouching_set_events_map_to_their_commands() {
let smart_brush = desc("null")
.with("smartBrushRadius", px(3.0))
.with("smartBrushSmooth", long(2.0))
.with("smartBrushFeather", px(0.5))
.with("smartBrushContrast", pct(10.0))
.with("smartBrushShiftEdge", pct(-20.0))
.with("sampleAllLayers", D::Boolean(false))
.with("smartBrushUseSmartRadius", D::Boolean(true))
.with("smartBrushDecontaminate", D::Boolean(false))
.with("smartBrushDeconAmount", pct(100.0))
.with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask"));
let selective = desc("null").with("Mthd", enm("CrcM", "Rltv")).with(
"ClrC",
D::List(vec![
D::Descriptor(
desc("ClrC").with("Clrs", enm("Clrs", "Ylws")).with("Cyn ", pct(-30.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(-40.0)).with("Blck", pct(0.0)),
),
D::Descriptor(
desc("ClrC").with("Clrs", enm("Clrs", "Whts")).with("Cyn ", pct(0.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(0.0)).with("Blck", pct(-10.0)),
),
]),
);
let lab = |l: f64| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(l)).with("A ", D::Double(0.0)).with("B ", D::Double(0.0)));
let sampled = desc("null").with("Fzns", long(40.0)).with("Mnm ", lab(80.0)).with("Mxm ", lab(95.0)).with("colorModel", long(0.0));
let mask = desc("null")
.with("Nw ", D::Class(class("Chnl")))
.with("At ", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]))
.with("Usng", enm("UsrM", "RvlS"));
let fg = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }]))
.with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0))));
let lab_fg = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("BckC") }])).with("T ", lab(50.0));
let items = vec![
item("autoCutout", "Select Subject", Some(desc("null").with("sampleAllLayers", D::Boolean(false)))),
item("smartBrushWorkspace", "Select and Mask", Some(smart_brush)),
item("selectiveColor", "Selective Color", Some(selective)),
item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "skinTone")).with("Fzns", long(40.0)))),
item("colorRange", "Color Range", Some(sampled)),
item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "Rds ")).with("UsFc", D::Boolean(true)))),
item("make", "Make", Some(mask)),
item("set", "Set", Some(fg)),
item("set", "Set", Some(lab_fg)),
item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))),
item("GrpL", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))),
item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", name_ref("Lyr ", "Shadow")))),
item("someFutureEvent", "Something New", Some(desc("null").with("Amnt", long(5.0)))),
];
let file = AtnFile {
name: UnicodeString::new_nul("Retouch"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = &imp.actions[0].steps;
let expected = [
("select.subject", json!({"sampleAllLayers": false})),
(
"select.refineEdge",
json!({"radius": 3, "smartRadius": true, "smooth": 2, "feather": 0.5, "contrast": 10, "shiftEdge": -20, "decontaminate": false, "amount": 100, "output": "layerMask", "sampleAllLayers": false}),
),
("image.adjustments.selectiveColor", json!({"method": "relative", "yellows": [-30, 0, -40, 0], "whites": [0, 0, 0, -10]})),
("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})),
(PHOTOSHOP_STEP, Value::Null),
(PHOTOSHOP_STEP, Value::Null),
("layer.layerMask.revealSelection", json!({})),
("tools.setColors", json!({"foreground": "#0000ff"})),
(PHOTOSHOP_STEP, Value::Null),
("layer.createClippingMask", json!({})),
("layer.createClippingMask", json!({})),
("layer.createClippingMask", json!({"layer": "Shadow"})),
(PHOTOSHOP_STEP, Value::Null),
];
assert_eq!(steps.len(), expected.len(), "{steps:?}");
for ((id, params), (want_id, want)) in steps.iter().zip(&expected) {
assert_eq!(id, want_id, "{steps:?}");
if *want_id != PHOTOSHOP_STEP {
assert_eq!(params, want, "{id}");
}
}
// Sampled Colors (Lab range), an unknown Color Range key, a Lab colour and an unknown
// event stay unsupported rather than play with other values.
let unsupported: Vec<(&str, usize)> = imp.unsupported.iter().map(|u| (u.event.as_str(), u.step)).collect();
assert_eq!(unsupported, [("colorRange", 4), ("colorRange", 5), ("set", 8), ("someFutureEvent", 12)]);
// A legacy PhotoCraft export's `Yllw` yellows still read.
let legacy = desc("null").with("ClrC", D::List(vec![D::Descriptor(desc("ClrC").with("Clrs", enm("Clrs", "Yllw")).with("Cyn ", pct(5.0)))]));
assert_eq!(selective_from(&legacy), Some(json!({"yellows": [5.0, 0.0, 0.0, 0.0]})));
// The mask of a named layer is not the target layer's mask: it stays unsupported.
let other_layer = desc("null")
.with("Nw ", D::Class(class("Chnl")))
.with(
"At ",
D::Reference(vec![
ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") },
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Other") },
]),
)
.with("Usng", enm("UsrM", "RvlA"));
assert!(matches!(make_from(&other_layer), Mapped::Unsupported { harmless: false }));
}
/// Select and Mask exports as the older `refineSelectionEdge` event, which has no Sample All
/// Layers; a step that sets it exports as `smartBrushWorkspace` so the value survives.
#[test]
fn select_and_mask_keeps_sample_all_layers_on_export() {
let (event, d) = event_and_descriptor("select.refineEdge", &json!({"radius": 3, "output": "layerMask"})).unwrap();
assert_eq!(event.name, "refineSelectionEdge");
assert!(d.get("refineEdgeBorderRadius").is_some());
for sample in [true, false] {
let params = json!({"radius": 3, "output": "layerMask", "sampleAllLayers": sample});
let (event, d) = event_and_descriptor("select.refineEdge", &params).unwrap();
assert_eq!(event.name, "smartBrushWorkspace");
let Mapped::Steps(steps) = (event.from)(&d) else { panic!("{d:?}") };
assert_eq!(steps[0].1["sampleAllLayers"], sample);
}
}
#[test]
fn crop_sides_in_other_units_are_refused_not_played_as_pixels() {
let rect = |unit: [u8; 4]| {
@@ -885,6 +1118,50 @@ fn remaining_events_import_in_their_photoshop_shapes() {
let rest_steps = &rest.actions[0].steps;
let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]);
// A transform without a reference (a replaced smart object) is the target layer's; the
// selection outline nudged; a layer moved to index 0 (the bottom); a View menu item; the
// foreground colour.
let replaced = desc("null")
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(4.0)).with("Vrtc", px(6.0))))
.with("Wdth", pct(50.0))
.with("Hght", pct(50.0))
.with("replaceLayer", D::Descriptor(desc("null")));
let nudged = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]))
.with("T ", D::Descriptor(desc("Ofst").with("Hrzn", px(3.0)).with("Vrtc", px(-2.0))));
let to_bottom = desc("null")
.with("null", target_ref("Lyr "))
.with("T ", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 0 }]))
.with("Adjs", D::Boolean(false));
let fit =
desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: Id::new("MnIt"), value: Id::new("FtOn") }]));
let fg = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }]))
.with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0))));
let items = vec![
item("transform", "Transform", Some(replaced)),
item("move", "Move", Some(nudged)),
item("move", "Move", Some(to_bottom)),
item("select", "Select", Some(fit)),
item("set", "Set", Some(fg)),
];
let file = AtnFile {
name: UnicodeString::new_nul("More2"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
};
let more = import(&atn::write(&file).unwrap()).unwrap();
let more_steps = &more.actions[0].steps;
assert_eq!(
more_steps.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
["edit.transform", "select.transformSelection", "layer.arrange.sendToBack", "view.fitOnScreen", "tools.setColors"]
);
assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50}));
assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2}));
assert_eq!(more_steps[4].1, json!({"foreground": "#000000"}));
assert!(more.unsupported.is_empty());
assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"}));
assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"}));
assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false}));
@@ -1281,6 +1558,28 @@ fn save_while_recording_includes_pending_steps() {
assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication");
}
/// #7233: the Minimum / Maximum descriptor carries the radius only, so a step with Preserve:
/// Roundness is refused by strict export (and left out, reported, otherwise) instead of replaying
/// as the default Squareness. Squareness, explicit or by default, still round-trips.
#[test]
fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() {
for id in ["filter.other.minimum", "filter.other.maximum"] {
let round = Action { name: "Round".into(), steps: vec![(id.into(), json!({"radius": 2, "preserve": "roundness"}))], ..Default::default() };
assert!(export("Audit", &[&round], &[], false).is_err(), "{id}: strict export refuses it");
let (_, skipped) = export("Audit", &[&round], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Round".into(), step: 0, id: id.into() }], "{id}");
for p in [json!({"radius": 2}), json!({"radius": 2, "preserve": "squareness"})] {
let square = Action { name: "Square".into(), steps: vec![(id.into(), p.clone())], ..Default::default() };
let (bytes, skipped) = export("Audit", &[&square], &[], false).unwrap();
assert!(skipped.is_empty(), "{id} {p}");
let imported = import(&bytes).unwrap();
let (got, params) = &imported.actions[0].steps[0];
assert_eq!((got.as_str(), params["radius"].as_f64()), (id, Some(2.0)), "{id} {p}");
assert!(params.get("preserve").is_none_or(|v| v == "squareness"), "{id} {p}: {params}");
}
}
}
/// Photoshop's Close has no document target: a Close of a specific document must not be
/// exported as a plain Close, which would close the active document on playback.
#[test]
@@ -1336,6 +1635,28 @@ fn arbitrary_rotation_keeps_interpolation_through_atn() {
}
}
/// Filter › Stylize › Emboss saves as Photoshop's `Embs` event with integer `Angl`, `Hght` and
/// `Amnt`, and loads back as the same step (#7946: it was skipped, so a portable action lost it).
#[test]
fn emboss_saves_as_photoshops_event_and_loads_back() {
let mut s = Session::new();
let params = json!({"angle": 130, "height": 1, "amount": 70});
s.actions.list.push(Action::new("Pore", vec![("filter.stylize.emboss".into(), params.clone())]));
let r = s.execute("actions.save", json!({"action": "Pore"})).unwrap();
assert_eq!(r["skipped"], json!([]));
let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&saved).unwrap();
let step = &file.actions[0].items[0];
assert_eq!(step.event.id(), "Embs");
let d = step.descriptor.as_ref().unwrap();
for (key, want) in [("Angl", 130), ("Hght", 1), ("Amnt", 70)] {
assert_eq!(d.get(key), Some(&D::Integer(want)), "{key}");
}
let mut t = Session::new();
t.execute("actions.load", json!({"data": b64(&saved)})).unwrap();
assert_eq!(t.actions.list[0].steps, vec![("filter.stylize.emboss".to_string(), params)]);
}
/// A filter recorded against a layer mask must not export as a plain filter that edits the
/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`.
#[test]
@@ -1401,3 +1722,51 @@ fn function_keys_keep_shift_and_command() {
}
assert_eq!(super::function_key_name(7, false, false), "F7");
}
/// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays
/// the missing key differently (checked against Photoshop): Canvas Size anchors top left,
/// Apply Image blends Normal, Color Balance turns preserve luminosity off, and a Paint Bucket
/// step without a tolerance doesn't play at all.
#[test]
fn defaults_photoshop_plays_differently_are_written() {
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
let bucket = exported("paint.bucket", json!({"x": 1, "y": 2}));
assert_eq!((get_num(&bucket, "Tlrn"), get_bool(&bucket, "AntA")), (Some(32.0), Some(true)));
let canvas = exported("image.canvasSize", json!({"width": 10, "height": 10}));
assert_eq!((get_enum(&canvas, "Hrzn").as_deref(), get_enum(&canvas, "Vrtc").as_deref()), (Some("Cntr"), Some("Cntr")));
let apply = exported("image.applyImage", json!({}));
assert_eq!(get_obj(&apply, "With").and_then(|w| get_enum(w, "Clcl")).as_deref(), Some("Mltp"));
let balance = exported("image.adjustments.colorBalance", json!({"midtones": [10, 0, 0]}));
assert_eq!(get_bool(&balance, "PrsL"), Some(true));
assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3));
}
/// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one.
#[test]
fn set_colors_exports_only_a_single_colour() {
assert!(export_item("tools.setColors", &json!({"foreground": "#ff0000", "background": "#0000ff"}), "S").is_none());
assert!(export_item("tools.setColors", &json!({"foregroundCmyk": [0, 0, 0, 100], "background": "#0000ff"}), "S").is_none());
assert!(export_item("tools.setColors", &json!({"background": "#0000ff"}), "S").is_some());
}
/// Photoshop's reset of an adjustment layer (`set` with an empty `T`) stays a reset: `kind`
/// only joins params that edit something, as any key makes `layer.setAdjustment` merge.
#[test]
fn adjustment_layer_reset_still_resets() {
let set = |t: Descriptor| desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t));
let reset = set(desc("HStr").with("presetKind", enm("presetKindType", "presetKindDefault")));
let edit = set(desc("ClrB").with("MdtL", D::List(vec![long(10.0), long(0.0), long(0.0)])));
let file = AtnFile {
name: UnicodeString::new_nul("Reset"),
expanded: false,
actions: vec![AtnAction {
name: UnicodeString::new_nul("A"),
items: vec![item("setd", "Set", Some(reset)), item("setd", "Set", Some(edit))],
..Default::default()
}],
warnings: vec![],
};
let steps = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0).steps;
assert_eq!(steps[0], ("layer.setAdjustment".to_string(), json!({})));
assert_eq!(steps[1].1, json!({"midtones": [10, 0, 0], "kind": "colorBalance"}));
}
+23
View File
@@ -513,6 +513,29 @@ fn define_brush_from_selection_survives_allocation_failure() {
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
}
#[test]
fn define_brush_from_selection_survives_failure_of_the_second_allocation() {
// Review of #5469: fail_next_for_test trips on the first buffer, so the second fallible
// call (the selection-mask read) did not have its own regression. Count the tip buffer as
// a clean attempt and fail the mask read instead; both report the same command purpose,
// and the command must still bow out before touching anything.
let mut s = session(40, 40);
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
let pixels_before = surface(&s);
let brush_before = s.tools.brush.clone();
let presets_before = s.tools.presets.clone();
let history_before = s.active().unwrap().history.past_len();
crate::allocation::fail_after_for_test(1);
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err(), "an allocation failure must be reported, not panic");
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for defining a brush from the selection")));
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
assert!(same_pixels(&pixels_before, &surface(&s), s.active().unwrap().doc.bounds()), "the document pixels are unchanged");
}
#[test]
fn works_on_cmyk_and_16_bit() {
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
+40 -9
View File
@@ -408,12 +408,18 @@ pub(crate) fn mask_at_depth(mut mask: LayerMask, depth: SampleType) -> Result<La
Ok(mask)
}
/// Options from the Layers Panel flyout live in preferences so headless/keyboard commands
/// behave exactly like pointer-triggered commands. Old preferences preserve Photoshop defaults.
pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool {
s.prefs().dialogs.get("ui.layersPanelOptions").and_then(|v| v.get(key)).and_then(Value::as_bool).unwrap_or(fallback)
}
/// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the
/// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector
/// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its
/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768).
/// A path that isn't there is an error.
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str) -> Result<()> {
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
let path = match p.get("path") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?),
@@ -424,7 +430,9 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
};
match path {
Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)),
None => l.mask = Some(mask_at_depth(selection_mask(doc)?.unwrap_or_else(LayerMask::reveal_all), doc.depth)?),
None => {
l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?;
}
}
Ok(())
}
@@ -433,12 +441,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
pub(crate) const NEW_LAYER_PATH: &str =
r##""path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask, or white with no selection)"##;
/// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask).
fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result<Value> {
let label = format!("New {} Layer", adj.label());
let name = adj.label().to_string();
let id = s.edit(&label, |doc, active| {
let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj));
mask_new_layer(doc, &mut l, p, cmd)?;
mask_new_layer(doc, &mut l, p, cmd, true)?;
let id = doc.insert_above(*active, l);
*active = Some(id);
Ok(id)
@@ -446,6 +455,7 @@ fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Res
Ok(json!({ "layer": id.0 }))
}
/// Apply `adj` in place to the targeted layer mask, the targeted alpha channel or Quick Mask, or else the active layer's pixels, as one history step named `label`.
fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) -> Result<Value> {
if is_mask_target(p) {
// The targeted layer mask (#780): ⌘I inverts it, as in Photoshop.
@@ -528,7 +538,7 @@ fn build() -> Vec<CommandSpec> {
"New…",
["File"],
Some("Cmd+N"),
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str}"##,
r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##,
always,
|s, p| {
use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type};
@@ -564,6 +574,18 @@ fn build() -> Vec<CommandSpec> {
}
};
doc.resolution_dpi = res;
// Photoshop's New Document › Color Profile: tag the document with another profile
// than the working space (#321). Checked before the document opens.
if let Some(spec) = p.get("profile").and_then(Value::as_str).filter(|spec| !matches!(*spec, "working" | "none")) {
let prof = s.color.resolve(spec, Some(&doc), None)?;
if prof.color_space != crate::color_cmds::mode_space(doc.mode) {
return Err(EngineError::BadParams {
cmd: "file.new".into(),
msg: format!("profile `{}` is {:?}; the document is {:?}", prof.description, prof.color_space, doc.mode),
});
}
doc.icc_profile = Some(prof.to_bytes());
}
let i = s.add_document(doc, None);
Ok(json!({ "document": i }))
}
@@ -616,6 +638,10 @@ fn build() -> Vec<CommandSpec> {
Ok(Value::Null)
}),
cmd!("select.deselect", "Deselect", ["Select"], Some("Cmd+D"), "{}", has_selection, |s, _| {
// Nothing selected: nothing to do and no history step, as in Photoshop (#6744).
if s.active().is_none_or(|d| d.doc.selection.is_none()) {
return Ok(Value::Null);
}
s.edit("Deselect", |doc, _| {
doc.selection = None;
Ok(())
@@ -751,8 +777,9 @@ fn build() -> Vec<CommandSpec> {
return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers");
}
let id = layer_param(s, p)?;
let add_copy = layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let nid = s.edit("Duplicate Layer", |doc, active| {
let mut dup = layer_copy(doc, id)?;
let mut dup = layer_copy(doc, id, add_copy)?;
if let Some(name) = &name {
dup.name.clone_from(name);
}
@@ -1028,10 +1055,11 @@ fn build() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?;
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
let id = s.edit("New Color Fill Layer", |doc, active| {
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor")?;
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?;
let id = doc.insert_above(*active, l);
*active = Some(id);
Ok(id)
@@ -1052,6 +1080,7 @@ fn build() -> Vec<CommandSpec> {
let angle = crate::gradient_fill_cmds::angle_param("layer.newFillLayer.gradient", p, 90.0)?;
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
let id = s.edit("New Gradient Fill Layer", |doc, active| {
let fill = Fill::gradient(
vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
@@ -1062,7 +1091,7 @@ fn build() -> Vec<CommandSpec> {
)
.in_mode(doc.mode);
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient")?;
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?;
let id = doc.insert_above(*active, l);
*active = Some(id);
Ok(id)
@@ -1698,12 +1727,14 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
/// A copy of layer `id` as Layer › Duplicate Layer makes it, not yet in the document: a fresh id,
/// "… copy" appended to the name, and a copy of the Background layer is an ordinary, unlocked
/// layer (Photoshop).
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
pub(crate) fn layer_copy(doc: &Document, id: LayerId, add_copy: bool) -> Result<Layer> {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
let from_background = doc.is_background(id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if add_copy {
dup.name = doc.copy_name(&dup.name);
}
if from_background {
dup.locks = Default::default();
}
+30
View File
@@ -191,6 +191,36 @@ fn smart_filters_reread_only_followed_files() {
assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered);
}
/// #7423 (review on #7799): Displace on a Smart Object keeps a `mapPath` as a path, as before
/// (only a map from an open document is embedded). A path relative to the saved document, set
/// through the filter's own edit, displaces as the absolute one did, and the dialog's "None"
/// (`mapDocument: -1`) names no map instead of failing.
#[test]
fn smart_displace_keeps_a_relative_map_path() {
let d = dir("displace-relative");
png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]);
png(&d.join("map.png"), |_| [255, 255, 255]);
let (mut s, id) = document(&d, &d.join("ramp.png"));
let path = d.join("map.png").to_string_lossy().into_owned();
let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px;
s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path, "horizontal": 50, "vertical": 0})).unwrap();
let state = |s: &Session| {
let doc = &s.active().unwrap().doc;
let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { panic!("a smart object") };
(sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).px)
};
let (params, displaced) = state(&s);
assert_eq!(params["mapPath"], json!(path));
assert!(photocraft_io::smart_map::displace_embedded_map(&params).is_none(), "a path, not an embedded map");
assert_ne!(displaced, unfiltered, "the map moves the pixels");
s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapPath": "map.png"}})).unwrap();
let (params, relative) = state(&s);
assert_eq!(params["mapPath"], json!("map.png"));
assert_eq!(relative, displaced, "a relative map next to the document");
s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapDocument": -1}})).unwrap();
assert_eq!(state(&s).1, displaced, "\"None\" changes nothing");
}
#[test]
fn data_sets_and_footage_read_only_followed_files() {
let d = dir("datasets");
+35
View File
@@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> {
continue;
}
"name" => (value.is_string(), "a string".into()),
"profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()),
_ => return Err(bad(cmd, format!("unknown parameter `{key}`"))),
};
if !valid {
@@ -101,6 +102,10 @@ pub struct DocumentSettings {
pub background: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_color: Option<[f32; 3]>,
/// `file.new`'s Color Profile (#321): a built-in profile id or an .icc path; absent is the
/// working space, as in presets saved before it existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
/// Display units only; width/height are always pixels and resolution is always ppi.
pub unit: String,
pub resolution_unit: String,
@@ -127,6 +132,9 @@ impl DocumentSettings {
if self.background_color.is_some_and(|c| !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))) {
return Err("Background colour components must be within 0..1".into());
}
if self.profile.as_deref().is_some_and(|p| p.trim().is_empty()) {
return Err("A saved Color Profile needs a profile id or path".into());
}
if !matches!(self.unit.as_str(), "px" | "in" | "cm" | "mm" | "pt" | "pica") || !matches!(self.resolution_unit.as_str(), "in" | "cm") {
return Err("Unknown dimension or resolution display unit".into());
}
@@ -170,6 +178,9 @@ impl DocumentSettings {
if let Some(c) = self.background_color {
p["backgroundColor"] = json!(c);
}
if let Some(profile) = &self.profile {
p["profile"] = json!(profile);
}
p
}
}
@@ -358,6 +369,27 @@ mod tests {
next
}
/// #321: a preset keeps New Document's Color Profile through a reload and back into
/// `file.new`; one saved without it (or before it existed) stays on the working space.
#[test]
fn the_color_profile_survives_save_reload_and_file_new() {
let mut s = Session::new();
let mut config = settings();
config["mode"] = json!("cmyk");
config["profile"] = json!("coated-cmyk");
let preset = saved(&mut s, "Press", config.clone());
assert_eq!(preset["settings"]["profile"], "coated-cmyk");
let next = reload(&s);
let params = next.presets.documents[0].settings.command_params();
assert_eq!(params["profile"], "coated-cmyk");
let mut s2 = Session::new();
s2.execute("file.new", params).unwrap();
assert!(s2.active().unwrap().doc.icc_profile.is_some());
let plain = saved(&mut s, "Plain", settings());
assert!(plain["settings"].get("profile").is_none());
assert!(s.presets.documents[1].settings.command_params().get("profile").is_none());
}
#[test]
fn every_field_survives_save_reload_and_file_new() {
for mode in ["rgb", "gray", "cmyk", "lab"] {
@@ -495,6 +527,9 @@ mod tests {
("backgroundColor", json!([2, 0, 0])),
("unit", json!("yards")),
("resolutionUnit", json!("px")),
("profile", json!("")),
("profile", json!(" \t")),
("profile", json!(12)),
] {
let mut config = settings();
config[key] = bad;
+32 -55
View File
@@ -131,10 +131,15 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut
Ok(Value::Null)
}
/// The effective whole-pixel Stroke width, shared by execution and ATN export.
pub(crate) fn stroke_width(p: &Value) -> usize {
// Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px).
p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize
}
/// Edit › Stroke: a band along the selection edge (or the layer's opaque edge without a selection).
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
// Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px).
let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize;
let width = stroke_width(p);
let color = color_param("edit.stroke", p, "color", s.tools.foreground)?;
// A colour picked as CMYK strokes a CMYK document with the picked inks (#4196).
let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]);
@@ -278,57 +283,6 @@ fn average(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn hash(x: i32, y: i32, seed: u32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
(h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32
}
/// Smooth value noise, summed over octaves (fBm), in 0..1.
pub(crate) fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 {
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0);
for o in 0..8u32 {
let (fx, fy) = (x * freq, y * freq);
let (ix, iy) = (fx.floor() as i32, fy.floor() as i32);
let (tx, ty) = (fx - ix as f32, fy - iy as f32);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let s = seed.wrapping_add(o * 7919);
let top = hash(ix, iy, s) + (hash(ix + 1, iy, s) - hash(ix, iy, s)) * sx;
let bot = hash(ix, iy + 1, s) + (hash(ix + 1, iy + 1, s) - hash(ix, iy + 1, s)) * sx;
sum += (top + (bot - top) * sy) * amp;
norm += amp;
amp *= 0.5;
freq *= 2.0;
}
// Stretch the bell-shaped sum so the clouds use the full foreground→background range.
((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0)
}
/// Filter › Render › Clouds / Difference Clouds, between the foreground and background colours.
fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result<Value> {
let (fg, bg) = (s.tools.foreground, s.tools.background);
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0) as u32;
let size = s.active().map_or(256.0, |d| d.doc.size.width.max(d.doc.size.height) as f32);
let base = (size / 4.0).clamp(16.0, 512.0);
map_pixels(s, if difference { "Difference Clouds" } else { "Clouds" }, p, |area, px, _| {
let w = area.width() as usize;
for (i, p) in px.iter_mut().enumerate() {
let (x, y) = ((area.x0 + (i % w) as i32) as f32, (area.y0 + (i / w) as i32) as f32);
let t = clouds_value(x, y, base, seed);
let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t);
if difference {
for k in 0..3 {
p[k] = (p[k] - c[k]).abs();
}
} else {
*p = [c[0], c[1], c[2], 1.0];
}
}
})
}
// ---------- transform presets ----------
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
@@ -1057,8 +1011,6 @@ pub fn specs() -> Vec<CommandSpec> {
show_only
),
spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average),
spec!("filter.render.clouds", "Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, false)),
spec!("filter.render.differenceClouds", "Difference Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, true)),
]
}
@@ -1541,6 +1493,31 @@ mod tests {
}
}
/// A selection masks Clouds but doesn't change their size: inside it they are the canvas's
/// clouds, as when Clouds sized itself by the document (before it became a filter).
#[test]
fn clouds_in_a_selection_are_the_canvas_clouds() {
// Big enough that the canvas (cloud size 100 px) and the selection (16 px) size differently.
let big = |depth: u32| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 300, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s
};
for depth in [8, 16, 32] {
let mut whole = big(depth);
whole.execute("filter.render.clouds", json!({"seed": 4})).unwrap();
let mut s = big(depth);
s.execute("select.rect", json!({"x": 50, "y": 40, "width": 60, "height": 50})).unwrap();
s.execute("filter.render.clouds", json!({"seed": 4})).unwrap();
for (x, y) in [(50, 40), (75, 60), (100, 85), (109, 89)] {
let (a, b) = (pixel(&s, x, y), pixel(&whole, x, y));
assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "depth {depth} at {x},{y}: {a:?} vs {b:?}");
}
assert_eq!(pixel(&s, 300, 200)[3], 0.0, "outside the selection the layer stays empty");
}
}
#[test]
fn average_and_clouds() {
let mut s = session(8);
+13 -5
View File
@@ -105,7 +105,7 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {
let mut out: Vec<String> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()).is_some_and(|x| OPENABLE.contains(&x.to_ascii_lowercase().as_str())))
.filter(|p| p.is_file() && openable_name(&p.to_string_lossy()))
.map(|p| p.to_string_lossy().into_owned())
.collect();
out.sort();
@@ -116,12 +116,20 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, or GIMP's
/// gzipped `.xcf.gz`, whose last extension alone says nothing.
#[cfg(not(target_arch = "wasm32"))]
fn openable_name(path: &str) -> bool {
let lower = file_name(path).to_ascii_lowercase();
lower.ends_with(".xcf.gz") || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext))
}
/// Extensions the batch commands pick up from a folder.
#[cfg(not(target_arch = "wasm32"))]
const OPENABLE: &[&str] = &[
"pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm",
"ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto",
"afpub",
"pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi",
"ico", "pnm", "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign",
"afphoto", "afpub",
];
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
@@ -802,7 +810,7 @@ fn exposure_text(t: f64) -> Option<String> {
return None;
}
let r = 1.0 / t;
if t < 1.0 && (r - r.round()).abs() <= r * 0.01 {
if t < 1.0 && r.round() > 1.0 && (r - r.round()).abs() <= r * 0.01 {
return Some(format!("1/{} s", r.round()));
}
Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 })))
+19
View File
@@ -1458,3 +1458,22 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
assert_eq!(std::fs::read(&first).unwrap(), before[0]);
assert_eq!(std::fs::read(&second).unwrap(), before[1]);
}
#[test]
fn exposure_text_near_one_second_is_not_one_over_one() {
assert_eq!(exposure_text(0.996).as_deref(), Some("1 s"));
assert_eq!(exposure_text(0.99).as_deref(), Some("0.99 s"));
assert_eq!(exposure_text(1.0 / 250.0).as_deref(), Some("1/250 s"));
}
/// A folder batch picks up GIMP's gzipped `.xcf.gz`, whose last extension alone (`gz`) says nothing.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() {
assert!(super::openable_name("/scans/page 01.xcf.gz"));
assert!(super::openable_name("C:\\scans\\PAGE.XCF.GZ"));
assert!(super::openable_name("page.xcfgz"));
assert!(super::openable_name("page.PNG"));
assert!(!super::openable_name("archive.tar.gz"));
assert!(!super::openable_name("notes.txt"));
}
+85 -5
View File
@@ -234,7 +234,10 @@ pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), Strin
pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
validate_params(id, p)?;
// Session state some filters read (colours) becomes explicit params; external inputs resolve here.
let prepared = crate::filters_ext::prepare(s, id, p);
let mut prepared = crate::filters_ext::prepare(s, id, p);
if id == "filter.distort.displace" {
prepared = crate::filters_ext::displace_request(&prepared);
}
let p = &prepared;
let mut fp = params_for(id, p).ok_or_else(|| EngineError::Other(format!("unknown filter {id}")))?;
crate::filters_ext::resolve(s, &mut fp, p)?;
@@ -262,9 +265,10 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
};
let selection = doc.selection.clone();
let sel_bounds = selection.as_ref().map(photocraft_raster::Surface::content_bounds);
// Distortions centre on the selection when there is one, else the canvas.
// Distortions centre on the selection when there is one, else the canvas. Clouds and
// Fibers place and size their pattern by the canvas either way; the selection masks them.
let doc_bounds = doc.bounds();
let bounds = sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds);
let bounds = if fp.sizes_by_canvas() { doc_bounds } else { sel_bounds.filter(|b| !b.is_empty()).unwrap_or(doc_bounds) };
// Neighbourhood filters repeat edge pixels at the canvas edge (or past it, where the
// layer has off-canvas pixels) instead of fading in transparency, as Photoshop does.
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(layer), &p)? {
@@ -297,9 +301,24 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
if matches!(fp, FilterParams::LensBlur { depth: algo::DepthSource::Channel, .. }) {
return Err(EngineError::Other("Lens Blur can't read an alpha channel as depth on a Smart Object: rasterize the layer first".into()));
}
// A smart filter re-renders from its params alone: a Displace map read from an
// open document, which the re-render can't read again, is kept in them, as
// Photoshop embeds it (#7423). A `mapPath` stays a path, read next to the
// document on each re-render.
let params = match &fp {
FilterParams::Displace { map: Some(map), .. }
if photocraft_io::smart_map::displace_embedded_map(&params).is_none() && crate::filters_ext::map_path(&params).is_none() =>
{
photocraft_io::smart_map::displace_embed_map(&params, map).ok_or_else(|| {
EngineError::Other(
"Displace: the displacement map is too large to keep in a Smart Object's filter: rasterize the layer first".into(),
)
})?
}
_ => params.clone(),
};
// Non-destructive: record the filter and re-render the smart object from its source.
let sf =
SmartFilter { command: id.clone(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
let sf = SmartFilter { command: id.clone(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
return crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref()).map(|()| fp);
}
_ => return Err(EngineError::Other("not a pixel layer".into())),
@@ -934,4 +953,65 @@ mod tests {
assert!(calls.load(std::sync::atomic::Ordering::SeqCst) > 2);
assert!(new.tiles().all(|(c, _)| got.tile(*c).is_some()));
}
/// #7423: Displace on a Smart Object keeps its map (read from an open document) in the smart
/// filter, as Photoshop embeds it, so the filter displaces and keeps displacing when the
/// object re-renders. A map chosen again replaces it.
#[test]
fn displace_on_a_smart_object_embeds_its_map() {
let mut s = Session::new();
// Documents 0 and 1: a white and a black displacement map.
s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8, "background": "black"})).unwrap();
s.execute("file.new", json!({"width": 48, "height": 32, "background": "transparent"})).unwrap();
s.edit("square", |doc, active| {
let fmt = doc.pixel_format();
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surf.fill_rect(photocraft_geom::Rect::new(10, 10, 20, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
Ok(())
})
.unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let original = {
let d = s.active().unwrap();
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
sm.cache.as_ref().unwrap().read_region(d.doc.bounds())
};
let smart = |s: &Session| {
let d = s.active().unwrap();
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
(sm.smart_filters[0].params.clone(), sm.cache.as_ref().unwrap().read_region(d.doc.bounds()))
};
let first_value = |params: &Value| photocraft_io::smart_map::displace_embedded_map(params).expect("an embedded map").data[0];
s.execute("filter.distort.displace", json!({"horizontal": 10, "vertical": 0, "mapDocument": 0})).unwrap();
let (params, displaced) = smart(&s);
assert!(params.get("mapDocument").is_none(), "{params}");
assert_eq!(first_value(&params), 1.0, "the white map");
assert_ne!(displaced, original, "the smart object is displaced");
// Re-rendered from its params alone (the filter hidden and shown again): still displaced.
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
assert_eq!(smart(&s).1, original);
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
assert_eq!(smart(&s).1, displaced);
// Another map chosen for the filter replaces the embedded one.
s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": {"mapDocument": 1}})).unwrap();
let (params, again) = smart(&s);
assert!(params.get("mapDocument").is_none(), "{params}");
assert_eq!(first_value(&params), 0.0, "the black map");
assert_ne!(again, displaced, "displaced the other way");
// The stored params passed back whole (the old embedded map included) with another map
// named: the named map wins, for the filter's own edit and for a new Displace alike.
let mut back = params.as_object().unwrap().clone();
back.insert("mapDocument".into(), json!(0));
s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": back.clone()})).unwrap();
assert_eq!(first_value(&smart(&s).0), 1.0, "the white map, not the embedded black one");
back.insert("mapDocument".into(), json!(1));
s.execute("filter.distort.displace", Value::Object(back)).unwrap();
let d = s.active().unwrap();
let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
assert_eq!(first_value(&sm.smart_filters[1].params), 0.0, "the black map, not the passed-back white one");
}
}
+25 -3
View File
@@ -173,6 +173,12 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
},
},
// ---- Render ----
"filter.render.clouds" | "filter.render.differenceClouds" => FilterParams::Clouds {
seed: seed(p),
difference: id == "filter.render.differenceClouds",
foreground: colour(p, "foreground", BLACK),
background: colour(p, "background", WHITE),
},
"filter.render.fibers" => FilterParams::Fibers {
variance: f(p, "variance", 16.0).clamp(1.0, 64.0),
strength: f(p, "strength", 4.0).clamp(1.0, 64.0),
@@ -401,7 +407,8 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
/// Ids whose rendering uses the foreground/background colours.
fn uses_colours(id: &str) -> bool {
matches!(id, "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize") || crate::gallery_cmds::uses_colours(id)
matches!(id, "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.stylize.tiles" | "filter.pixelate.pointillize")
|| crate::gallery_cmds::uses_colours(id)
}
/// Fills in session state a filter reads (the current colours) as explicit params, so the
@@ -450,7 +457,7 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
let layer = p.get("mapLayer").and_then(Value::as_u64);
let img = if let Some(path) = map_path(p) {
map_from_path(path, layer)?
} else if let Some(d) = p.get("mapDocument") {
} else if let Some(d) = map_document(p) {
let docs = s.documents();
let found = match d {
Value::String(name) => docs.iter().find(|ds| ds.doc.name == *name || ds.path.as_deref() == Some(name.as_str())),
@@ -465,10 +472,23 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
Ok(())
}
fn map_path(p: &Value) -> Option<&str> {
/// Displace's params as a request for its map: a map named in them (`mapPath` or `mapDocument`)
/// wins over the one already embedded, which is read only when none is named. Otherwise a dialog
/// or agent passing the filter's stored params back with a new map got the old map again.
pub(crate) fn displace_request(p: &Value) -> Value {
if map_path(p).is_some() || map_document(p).is_some() { photocraft_io::smart_map::displace_without_map(p) } else { p.clone() }
}
pub(crate) fn map_path(p: &Value) -> Option<&str> {
p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty())
}
/// Displace's `mapDocument`: an index or a name. Null and a negative index (the dialog's "None")
/// name no document.
pub(crate) fn map_document(p: &Value) -> Option<&Value> {
p.get("mapDocument").filter(|d| !d.is_null() && !d.as_i64().is_some_and(|i| i < 0))
}
/// Displace's map read from a file (flattened, or its `mapLayer`).
fn map_from_path(path: &str, layer: Option<u64>) -> Result<Option<algo::Image>> {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
@@ -628,6 +648,8 @@ pub fn specs() -> Vec<CommandSpec> {
r##"{"amount":-100..100=10,"ridges":0..20=5,"style":"pondRipples|outFromCenter|aroundCenter"}"##
),
// Render
cmd!("filter.render.clouds", "Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##),
cmd!("filter.render.differenceClouds", "Difference Clouds", ["Filter", "Render"], r##"{"seed":u32=0,"foreground":json,"background":json}"##),
cmd!(
"filter.render.fibers",
"Fibers…",
+117 -29
View File
@@ -32,6 +32,7 @@ pub struct CutParts {
pub layer: LayerId,
rest: Surface,
piece: Surface,
piece_bounds: photocraft_geom::Rect,
/// The pieces are the layer's mask (`"target":"mask"`), not its pixels.
mask: bool,
}
@@ -55,41 +56,27 @@ impl CutParts {
};
let fmt = surf.format();
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
let full = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
let mut rest = if mask { surf.clone() } else { full.clone() };
let base = if mask || fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
let mut rest = base.clone();
let mut piece = Surface::new(with_alpha);
let fill = !copy && (mask || doc.is_background(layer));
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { base.content_bounds() });
if !b.is_empty() {
let n = with_alpha.channels();
let a = n - 1;
let mut rp = full.read_region(b);
let mut pp = rp.clone();
let w = b.width() as usize;
for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() {
let k = sel.sample_channel(b.x0 + (i % w) as i32, b.y0 + (i / w) as i32, 0);
if k <= 0.0 {
p.fill(0.0);
} else {
p[a] *= k;
}
if !copy && !fill {
r[a] *= 1.0 - k;
}
}
if !mask {
rest.write_region(b, &rp);
}
piece = cut_piece(&base, sel, b, with_alpha)?;
if fill {
crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?;
let opaque_bg = [background[0], background[1], background[2], 1.0];
crate::pixels::try_fill_surface(&mut rest, b, opaque_bg, Some(sel), false)?;
if mask {
rest.prune();
}
} else if !copy && !mask {
crate::pixels::try_lift_coverage(&mut rest, b, sel)?;
}
rest.prune();
piece.write_region(b, &pp);
piece.prune();
}
Ok(Self { layer, rest, piece, mask })
let piece_bounds = piece.content_bounds();
Ok(Self { layer, rest, piece, piece_bounds, mask })
}
/// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer.
@@ -107,10 +94,14 @@ impl CutParts {
} else {
l.surface_mut().ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?
};
if self.piece_bounds.is_empty() {
*surf = self.rest.clone();
return Ok(());
}
let mut out = self.rest.clone();
let piece = photocraft_algo::resample::translate_surface(&self.piece, dx, dy);
let piece = self.piece.translated(dx, dy, self.piece_bounds);
if self.mask {
crate::fill_cmds::composite_over(&mut out, &piece, piece.content_bounds(), None)?;
crate::fill_cmds::composite_over(&mut out, &piece, self.piece_bounds.translate(dx, dy), None)?;
} else {
crate::transform_cmds::composite_over(&mut out, &piece);
}
@@ -119,6 +110,103 @@ impl CutParts {
}
}
/// Extract the selected pixels of `base` over `b` into a `with_alpha` surface, tile by tile in
/// parallel. Fully selected tiles on an alpha surface share `base`'s `Arc<Tile>` directly.
fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha: PixelFormat) -> Result<Surface> {
use photocraft_color::{SampleType, read_sample};
use photocraft_geom::{TILE_SIZE, TileCoord};
use photocraft_raster::Tile;
use rayon::prelude::*;
let mut piece = Surface::new(with_alpha);
let src_fmt = base.format();
let same_fmt = src_fmt == with_alpha;
let n = with_alpha.channels();
let a = n - 1;
let dst_bpp = with_alpha.bytes_per_pixel();
let src_bpp = src_fmt.bytes_per_pixel();
let sb = with_alpha.sample.bytes();
let alpha_off = dst_bpp - sb;
let src_empty_default = same_fmt && base.default_bytes().iter().all(|&v| v == 0);
let oom = |e| EngineError::Other(format!("not enough memory while floating selection ({e}); the document was not changed"));
let mut coords = crate::allocation::capacity(b.tiles().count(), "floating selection")?;
coords.extend(b.tiles());
let tiles = coords
.into_par_iter()
.map(|tc| -> Result<Option<(TileCoord, Arc<Tile>)>> {
let tr = tc.rect().intersect(&b);
let mask = crate::fill_cmds::Mask::of(sel, tc);
if mask.empty(tr) {
return Ok(None);
}
let src_tile = base.tile(tc);
if same_fmt && src_tile.is_none() && src_empty_default {
return Ok(None);
}
if same_fmt
&& tr == tc.rect()
&& mask.full(tr)
&& let Some(t) = src_tile
{
return Ok(Some((tc, t.clone())));
}
let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes());
let mut out = piece.try_default_tile().map_err(oom)?;
let out_bytes = out.bytes_mut();
let mut px = [0.0f32; 8];
let mut has_content = false;
for y in tr.y0..tr.y1 {
let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize;
for x in tr.x0..tr.x1 {
let k = mask.at(x, y);
if k <= 0.0 {
continue;
}
let idx = row + (x - tc.tx * TILE_SIZE) as usize;
let dst_i = idx * dst_bpp;
let Some(dst_px) = out_bytes.get_mut(dst_i..dst_i + dst_bpp) else { continue };
let src_i = if src_bytes.len() == src_bpp { 0 } else { idx * src_bpp };
let Some(src_px) = src_bytes.get(src_i..src_i + src_bpp) else { continue };
if same_fmt && k >= 1.0 {
if src_px.iter().any(|&v| v != 0) {
dst_px.copy_from_slice(src_px);
has_content = true;
}
continue;
}
if same_fmt {
for (c, v) in px.iter_mut().enumerate().take(n) {
*v = read_sample(src_px, src_fmt.sample, c);
}
// The selection lifts its share of a pixel's coverage, up to all of it
// (Photoshop): a layer's own transparency, loaded as the selection, lifts
// it whole (#7655). An opaque pixel splits as k and 1 − k.
px[a] = px[a].min(k);
} else {
for (c, v) in px.iter_mut().enumerate().take(a) {
*v = read_sample(src_px, src_fmt.sample, c);
}
px[a] = k;
}
if px[a] <= 0.0 {
continue;
}
photocraft_raster::encode_pixel(&with_alpha, &px[..n], dst_px);
let non_zero = match with_alpha.sample {
SampleType::U8 => dst_px.get(alpha_off) != Some(&0) || dst_px.iter().any(|&v| v != 0),
SampleType::U16 => dst_px.get(alpha_off..dst_bpp) != Some(&[0, 0]) || dst_px.iter().any(|&v| v != 0),
SampleType::F32 => dst_px.iter().any(|&v| v != 0),
};
has_content |= non_zero;
}
}
Ok(has_content.then(|| (tc, Arc::new(out))))
})
.collect::<Result<Vec<_>>>()?;
piece.put_tiles(tiles.into_iter().flatten());
Ok(piece)
}
/// A piece floating `offset` whole pixels from where it was cut, over document `revision`.
#[derive(Clone, Debug)]
pub struct Floating {
+110
View File
@@ -226,3 +226,113 @@ fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colo
assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour");
assert_eq!(mask_at(&s, 40, 12), 0.0);
}
/// #7655: a selection loaded from the layer's own transparency lifts its half-transparent pixels
/// whole, leaving nothing behind (Photoshop); a half selection of an opaque pixel still splits it.
#[test]
fn a_layers_own_transparency_floats_its_soft_pixels_whole() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 60, "background": "transparent"})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("paint", |doc, _| {
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
surf.fill_rect(Rect::new(10, 10, 20, 30), &[1.0, 0.0, 0.0, 0.5]);
surf.fill_rect(Rect::new(20, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap();
s.execute("select.float", json!({"dx": 0, "dy": 25})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(alpha(d, id, 15, 20), 0.0, "no ghost where the soft pixels were");
assert!((alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "they moved whole: {}", alpha(d, id, 15, 45));
assert_eq!((alpha(d, id, 25, 20), alpha(d, id, 25, 45)), (0.0, 1.0));
// A copy (⌥) of them is as solid as they are, as Photoshop's Layer via Copy of the same
// selection is, and leaves them where they were.
s.undo();
s.execute("select.loadSelection", json!({"channel": "transparency"})).unwrap();
s.execute("select.float", json!({"dx": 0, "dy": 25, "copy": true})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!((alpha(d, id, 15, 20) - 0.5).abs() < 0.01 && (alpha(d, id, 15, 45) - 0.5).abs() < 0.01, "{} {}", alpha(d, id, 15, 20), alpha(d, id, 15, 45));
// A half selection of an opaque pixel splits it half and half, as before.
let (mut s, id) = session();
s.execute("select.all", json!({})).unwrap();
s.edit("half", |doc, _| {
let sel = doc.selection.as_mut().unwrap();
sel.fill_rect(doc_rect(80, 60), &[0.5]);
Ok(())
})
.unwrap();
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!((alpha(d, id, 15, 15) - 0.5).abs() < 0.01 && (alpha(d, id, 55, 15) - 0.5).abs() < 0.01);
}
fn doc_rect(w: i32, h: i32) -> Rect {
Rect::new(0, 0, w, h)
}
#[test]
fn float_shares_interior_tiles_on_cut_and_place() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
s.tools.background = [0.2, 0.4, 0.8, 1.0];
let id = s.active().unwrap().active_layer.unwrap();
// Paint non-uniform pixels so original tiles are distinct.
s.edit("pattern", |doc, _| {
let surf = doc.layer_mut(id).unwrap().surface_mut().unwrap();
for ty in 0..4 {
for tx in 0..4 {
surf.write_pixel(tx * 256 + 5, ty * 256 + 5, &[tx as f32 * 0.25, ty as f32 * 0.25, 0.5, 1.0]);
}
}
Ok(())
})
.unwrap();
// Select (100..900)^2, which fully covers interior tiles (1, 1) and (2, 2).
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 800, "height": 800})).unwrap();
s.execute("select.float", json!({})).unwrap();
let st = s.active().unwrap();
let orig = st.doc.layer(id).unwrap().surface().unwrap();
let parts = &floating(st).unwrap().parts;
let tc1 = photocraft_geom::TileCoord { tx: 1, ty: 1 };
let tc2 = photocraft_geom::TileCoord { tx: 2, ty: 2 };
// Interior tiles of `piece` share the original layer's Arc<Tile>.
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), parts.piece.tile(tc1).unwrap()));
assert!(Arc::ptr_eq(orig.tile(tc2).unwrap(), parts.piece.tile(tc2).unwrap()));
// Interior tiles of the Background hole in `rest` share one solid fill Arc<Tile>.
assert!(Arc::ptr_eq(parts.rest.tile(tc1).unwrap(), parts.rest.tile(tc2).unwrap()));
// Moving by a whole tile (256, 0) shares the interior tile directly in the preview document.
let shown = displayed(st, (256, 0)).unwrap();
let shown_surf = shown.layer(id).unwrap().surface().unwrap();
let moved_tc = photocraft_geom::TileCoord { tx: 2, ty: 1 };
assert!(Arc::ptr_eq(orig.tile(tc1).unwrap(), shown_surf.tile(moved_tc).unwrap()));
}
#[test]
fn float_feathered_selection_preserves_partial_alpha_across_depths() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 80, "background": "transparent", "depth": depth})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.edit("paint", |doc, _| {
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 55, 80), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 30, "height": 30, "feather": 4.0})).unwrap();
s.execute("select.float", json!({"dx": 40, "dy": 0})).unwrap();
s.execute("select.drop", json!({})).unwrap();
let surf = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
// Center of moved piece is opaque red; center of hole is transparent; feathered edge has partial alpha.
assert!(surf.rgba(75, 35)[3] > 0.95, "{depth}-bit moved center");
assert!(surf.rgba(35, 35)[3] < 0.05, "{depth}-bit hole center");
let edge_a = surf.rgba(60, 35)[3];
assert!(edge_a > 0.1 && edge_a < 0.9, "{depth}-bit feathered edge alpha={edge_a}");
}
}
+42 -8
View File
@@ -1,8 +1,8 @@
//! Layers panel: showing or hiding a layer's effects list under its row (the triangle beside
//! the fx badge, #144). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
//! Layers panel: showing or hiding a layer's effects and Smart Filters under its row (the
//! shared disclosure triangle). View state ([`crate::DocState::fx_collapsed`]): no history step, and a
//! clean document stays clean.
use photocraft_doc::LayerId;
use photocraft_doc::{Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
@@ -10,15 +10,23 @@ use crate::{EngineError, Result, Session};
const ID: &str = "layer.setEffectsExpanded";
/// A `layer.setEffectsExpanded` parameter error.
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
/// The command is available while some layer has effects or Smart Filters to disclose.
fn has_effects(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) }
if d.doc.walk().iter().any(|(_, _, l)| has_children(l)) { Ok(()) } else { Err("no layer has effects or smart filters".into()) }
}
/// Whether the layer has a list to disclose: effects, or Smart Filters on a Smart Object.
fn has_children(l: &Layer) -> bool {
!l.effects.items.is_empty() || matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty())
}
/// An optional boolean param: `None` when absent or null, an error when it is anything but true or false.
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
@@ -37,12 +45,12 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => Some(st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?),
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?)),
};
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| !l.effects.items.is_empty()).map(|(_, _, l)| l.id).collect();
let with_fx: Vec<LayerId> = st.doc.walk().iter().filter(|(_, _, l)| has_children(l)).map(|(_, _, l)| l.id).collect();
let current = match layer {
Some(id) => {
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if l.effects.items.is_empty() {
return Err(bad(format!("layer {} has no effects", id.0)));
if !has_children(l) {
return Err(bad(format!("layer {} has no effects or smart filters", id.0)));
}
!st.fx_collapsed.contains(&id)
}
@@ -59,13 +67,14 @@ fn set_expanded(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"expanded": expanded, "layers": targets.len()}))
}
/// Register `layer.setEffectsExpanded`: view state, so it is not journaled and adds no undo step.
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Expand/Collapse Effects",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects; view state, not an undo step)"##,
params: r##"{"layer":id?,"expanded":bool?,"all":bool?} (no expanded: toggle; all: every layer with effects or smart filters; view state, not an undo step)"##,
enabled: has_effects,
run: set_expanded,
journal: false,
@@ -88,10 +97,35 @@ mod tests {
(s, a, b)
}
/// Whether the layer's effects list is shown, i.e. it is not in the document's collapsed set.
fn open(s: &Session, id: u64) -> bool {
!s.active().unwrap().fx_collapsed.contains(&LayerId(id))
}
/// Smart Filters hide and show with the layer's effects, as view state: no document edit, revision or history step.
#[test]
fn smart_filters_share_the_effects_disclosure_without_an_edit() {
let (mut s, a, b) = session();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("image.adjustments.invert", json!({})).unwrap();
let smart = s.active().unwrap().active_layer.unwrap();
let doc = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let steps = s.active().unwrap().history.past_len();
s.execute(ID, json!({"layer": smart.0, "expanded": false})).unwrap();
assert!(!open(&s, smart.0));
assert!(open(&s, b));
assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc));
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.active().unwrap().history.past_len(), steps);
s.execute(ID, json!({"expanded": false, "all": true})).unwrap();
assert!(!open(&s, smart.0) && !open(&s, b));
s.execute(ID, json!({"expanded": true, "all": true})).unwrap();
assert!(s.active().unwrap().fx_collapsed.is_empty());
}
/// Toggling one layer or all of them flips only the disclosure state: no history step, no duplicates, and the document stays clean.
#[test]
fn toggles_one_layer_or_all_without_history() {
let (mut s, a, b) = session();
+3
View File
@@ -102,6 +102,9 @@ mod tests {
s.execute("layer.new.layer", json!({"name": "deep"})).unwrap();
let inner = s.execute("layer.groupLayers", json!({"name": "inner"})).unwrap()["layer"].as_u64().unwrap();
let outer = s.execute("layer.groupLayers", json!({"layer": inner, "name": "outer"})).unwrap()["layer"].as_u64().unwrap();
// Group Layers creates collapsed groups; the tests start from both open.
assert!(!expanded(&s, inner) && !expanded(&s, outer));
s.execute(ID, json!({"all": true, "expanded": true})).unwrap();
(s, inner, outer)
}
+3 -1
View File
@@ -59,8 +59,10 @@ fn select_state(s: &mut Session, p: &Value) -> Result<Value> {
}
let damage = crate::layer_multi_cmds::step_damage(&start, &doc);
st.doc = doc;
// Stepping only over selection changes keeps a clean document clean (#6865).
let keep_clean = st.keeps_clean(&start);
crate::restore_target(st, layers);
st.revision += 1;
st.bump_revision(keep_clean);
st.last_damage = damage;
if at != target {
// Not reached (the bounds were checked): the document is consistent at the state reached.
+13
View File
@@ -273,6 +273,19 @@ pub(crate) fn check_resample_budget(cmd: &str, doc: &Document, nw: u32, nh: u32,
Err(bad(cmd, format!("{nw} x {nh} px is {mp} megapixels; resampling at this bit depth is limited to {max_mp} megapixels. Choose a smaller size{hint}")))
}
/// Like [`check_resample_budget`] for a command that, besides resampling the document to
/// `nw` x `nh` px, holds `work_bytes_per_px` more bytes per pixel of full-canvas working buffers
/// (a composite, a mask, an output layer) at once.
pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, work_bytes_per_px: u64, hint: &str) -> Result<()> {
let bpp = (doc.pixel_format().bytes_per_pixel().max(1) as u64).saturating_add(work_bytes_per_px);
let px = u64::from(nw).saturating_mul(u64::from(nh));
if px.saturating_mul(bpp) <= MAX_RESAMPLE_BYTES {
return Ok(());
}
let max_mp = MAX_RESAMPLE_BYTES / bpp / 1_000_000;
Err(bad(cmd, format!("{nw} x {nh} px is {} megapixels; this conversion is limited to {max_mp} megapixels. Choose a smaller size{hint}", px / 1_000_000)))
}
/// Resamples the whole document (pixels, masks, channels, selection, effects, vectors, guides) to
/// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution
/// mode. Callers check [`check_resample_budget`] first.
+56 -1
View File
@@ -212,6 +212,10 @@ struct Running {
label: String,
params: Value,
journal: bool,
/// The recording (`ActionState::take`) running when the job started, if any (set by
/// `dispatch`, once recording resumes). Only that recording gets the job's step: one started
/// later must not pick up an edit made before it.
recorded_by: Option<u64>,
doc: Option<DocId>,
/// The document as the job saw it: applying fails if it was edited meanwhile.
snapshot: Option<Weak<Document>>,
@@ -400,6 +404,7 @@ pub fn run<T: Send + 'static>(
label: label.to_string(),
params: Value::Null,
journal: true,
recorded_by: None,
doc,
snapshot,
ctx,
@@ -532,6 +537,15 @@ impl Session {
})?;
ctx.progress(0.95, "Preparing layers");
warm_layer_bounds(&imported.document, || ctx.check())?;
// Type layers need the shared text engine, whose first use waits for the
// installed-font scan started at launch (minutes on some machines). Wait here,
// so the shell's missing-font check after the open finds it ready instead of
// freezing the window (#7332).
if crate::type_extra_cmds::has_type_layers(&imported.document) {
ctx.progress(0.97, "Loading fonts");
let _ = photocraft_text::shared();
ctx.check()?;
}
Ok(imported)
},
move |s, mut r: photocraft_io::ImportResult| {
@@ -748,6 +762,26 @@ impl Session {
}
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = self.admit(id, spec, &params)?;
// This UI-only disclosure preference applies to both `execute` and `start`:
// desktop UI normally calls `start`, even for synchronous Layer Style commands.
// The layer the command will change, named as the command accepts it (id or name), else the
// active one. `layer.layerStyle.clear` needs no case: it leaves no effects, so nothing expands.
// Transfer Effects changes its destination (`to`), not the layer it takes the effects from.
let new_fx_target = if id.starts_with("layer.layerStyle.") {
let target = if id == "layer.layerStyle.transferEffects" {
crate::commands::layer_param(self, &json!({"layer": params.get("to").cloned().unwrap_or(Value::Null)}))
} else {
crate::commands::layer_param(self, &params)
}
.ok();
self.active().and_then(|st| {
let target = target?;
(st.doc.layer(target)?.effects.items.is_empty()).then_some((st.doc.id, target))
})
} else {
None
};
let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true);
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.jobs.spawn = background;
@@ -780,6 +814,7 @@ impl Session {
job.command = id.to_string();
job.params = params;
job.journal = spec.journal;
job.recorded_by = self.actions.recording.map(|_| self.actions.take);
let jid = job.id;
self.jobs.running.push(job);
Ok(Started::Job(jid))
@@ -788,6 +823,17 @@ impl Session {
if let Some(before) = before {
self.add_relayout_damage(&before);
}
if let Some((doc_id, layer_id)) = new_fx_target
&& let Some(st) = self.active_mut()
&& st.doc.id == doc_id
&& st.doc.layer(layer_id).is_some_and(|l| !l.effects.items.is_empty())
{
if expand_new_fx {
st.fx_collapsed.retain(|id| *id != layer_id);
} else if !st.fx_collapsed.contains(&layer_id) {
st.fx_collapsed.push(layer_id);
}
}
self.after_command(id, params, spec.journal, Some(nested_from));
Ok(Started::Done(v))
}
@@ -868,7 +914,13 @@ impl Session {
}
fn apply_job(&mut self, job: &Running, apply: Option<Apply>, payload: Payload) -> Result<Value> {
let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, .. } = job;
let Running { label, doc, snapshot, coalesce, color_restrict, layers, command, params, journal, recorded_by, .. } = job;
// A recording started after the job (#7289) keeps what it has so far, and the job's step
// goes into the journal past its start, so that recording doesn't get it.
let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take);
if foreign {
crate::actions_cmds::flush_recording(self)?;
}
let prev_active = self.active;
let target = match doc {
Some(d) => {
@@ -917,6 +969,9 @@ impl Session {
// to that document instead, when the user switched tabs mid-job.
if r.is_ok() {
self.after_command(command, params.clone(), *journal, Some(nested_from));
if foreign && let Some((start, _)) = self.actions.recording.as_mut() {
*start = self.journal.len();
}
}
// Give the user back the layers they selected while the job ran, unless the job chose a
// selection itself (a new layer, say) or one of their layers is gone.
+72
View File
@@ -94,6 +94,36 @@ fn free_transform_runs_as_a_job_with_the_inline_result() {
assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command");
}
#[test]
fn warp_runs_as_a_job_with_the_inline_result() {
let arc = json!({"style": "arc", "bend": 30});
let mut inline = session(300, 200);
inline.execute("edit.transform.warp", arc.clone()).unwrap();
let mut s = session(300, 200);
let steps = s.active().unwrap().history.past_len();
let id = job(s.start("edit.transform.warp", arc).unwrap());
let e = wait_event(&mut s, id);
assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}");
assert_eq!(e.label, "Warp");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step");
assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command");
}
#[test]
fn a_cancelled_warp_leaves_the_document_unchanged() {
let mut s = session(1600, 1200);
let doc_before = s.active().unwrap().doc.clone();
let steps = s.active().unwrap().history.past_len();
let id = job(s.start("edit.transform.warp", json!({"style": "flag", "bend": 40})).unwrap());
assert!(s.cancel_job(id));
assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled);
s.join_cancelled_jobs();
let st = s.active().unwrap();
assert!(Arc::ptr_eq(&st.doc, &doc_before), "the document snapshot was not replaced");
assert_eq!(st.history.past_len(), steps);
}
#[test]
fn cancel_leaves_the_document_byte_identical() {
let mut s = session(1600, 1200);
@@ -605,3 +635,45 @@ fn an_inline_job_ctx_lets_another_thread_cancel_inline_work() {
s.set_inline_job_ctx(None);
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
}
/// The command ids an action holds.
fn action_steps(s: &mut Session, name: &str) -> Vec<String> {
let a = s.execute("actions.get", json!({"action": name})).unwrap();
a["steps"].as_array().unwrap().iter().map(|step| step[0].as_str().unwrap().to_string()).collect()
}
/// A job's result is recorded only by the recording it was started in: one started later keeps
/// its own steps around it but doesn't pick up the older edit (#7289).
#[test]
fn a_job_lands_in_the_recording_it_was_started_in_only() {
// Started while recording A, applied after A stopped and B started.
let mut s = session(40, 30);
s.execute("actions.record", json!({"name": "A"})).unwrap();
let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap());
s.execute("actions.stop", json!({})).unwrap();
s.execute("actions.record", json!({"name": "B"})).unwrap();
// B's own steps before and after the result lands stay in B (a new document, so the job's
// document is unchanged and its result applies).
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.wait_job(id).unwrap();
s.execute("image.adjustments.desaturate", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert!(action_steps(&mut s, "A").is_empty());
assert_eq!(action_steps(&mut s, "B"), ["file.new", "image.adjustments.desaturate"]);
// Started before any recording, applied while B records.
let mut s = session(40, 30);
let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap());
s.execute("actions.record", json!({"name": "B"})).unwrap();
s.wait_job(id).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert!(action_steps(&mut s, "B").is_empty());
// Started and applied while A records: A has it, as before.
let mut s = session(40, 30);
s.execute("actions.record", json!({"name": "A"})).unwrap();
let id = job(s.start("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap());
s.wait_job(id).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(action_steps(&mut s, "A"), ["filter.blur.gaussianBlur"]);
}
+505 -36
View File
@@ -6,7 +6,7 @@
//! changing it never adds a history step. Every structural change below is one history step.
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, VideoData};
use photocraft_geom::Rect;
use serde_json::{Value, json};
@@ -740,21 +740,107 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
/// [`step_damage`] but without computing any bounds. Cheap: pixels compare by tile pointer.
/// Conservative (`false`) past a nesting depth no real document reaches.
pub fn same_pixels(a: &Document, b: &Document) -> bool {
fn walk(xs: &[Layer], ys: &[Layer], depth: usize) -> bool {
pixels_equal_with(a, b, |_, _| false)
}
/// [`same_pixels`], with video layers equal when `video_equal` says their frame stacks are.
fn pixels_equal_with(a: &Document, b: &Document, video_equal: fn(&VideoData, &VideoData) -> bool) -> bool {
fn walk(xs: &[Layer], ys: &[Layer], depth: usize, video_equal: fn(&VideoData, &VideoData) -> bool) -> bool {
depth < 256
&& xs.len() == ys.len()
&& xs.iter().zip(ys).all(|(x, y)| {
x.video.is_none()
&& y.video.is_none()
&& layer_pixels_equal(x, y)
layer_pixels_equal(x, y)
&& match (&x.video, &y.video) {
(None, None) => true,
(Some(vx), Some(vy)) => video_equal(vx, vy),
_ => false,
}
&& match (x.children(), y.children()) {
(Some(xc), Some(yc)) => walk(xc, yc, depth + 1),
(Some(xc), Some(yc)) => walk(xc, yc, depth + 1, video_equal),
(None, None) => true,
_ => false,
}
})
}
doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0)
doc_pixels_equal(a, b) && walk(&a.layers, &b.layers, 0, video_equal)
}
/// Whether two video layers hold the same frames: the same settings and stack shape
/// (`VideoData`'s own, shallow equality) and the same tile pointers in every frame, painted and
/// original. Pointer compares only, like [`surfaces_equal`].
fn video_frames_equal(a: &VideoData, b: &VideoData) -> bool {
let frames = |x: &[photocraft_raster::Surface], y: &[photocraft_raster::Surface]| x.len() == y.len() && x.iter().zip(y).all(|(f, g)| surfaces_equal(f, g));
*a == *b
&& frames(&a.frames, &b.frames)
&& match (&a.original_frames, &b.original_frames) {
(Some(x), Some(y)) => frames(x, y),
(None, None) => true,
_ => false,
}
}
/// Whether `a` and `b` differ at most in their selection (#6865): a selection step, which must
/// not mark a clean document modified. Pixels compare by tile pointer (as in [`same_pixels`],
/// plus every frame of a video layer, whose own equality is shallow); every other field compares
/// structurally, where copy-on-write `Arc`s of `Eq` data short-circuit on pointer identity. The
/// destructure is exhaustive, so a new `Document` field fails to compile until it is classified.
pub fn selection_only_change(a: &Document, b: &Document) -> bool {
let Document {
id,
name,
size: _,
resolution_dpi: _,
mode: _,
depth: _,
icc_profile: _,
layers,
channels: _,
guides,
ruler_origin,
selection: _,
metadata,
global_light: _,
paths,
work_path,
clipping_path,
quick_mask: _,
quick_mask_unselected,
patterns: _,
color_table: _,
duotone: _,
layer_comps,
last_applied_comp,
last_document_state,
measurement,
notes,
text_styles,
slices,
variables,
timeline,
file_encoding,
} = a;
// The ignored fields above are the pixel ones `pixels_equal_with` already compares.
pixels_equal_with(a, b, video_frames_equal)
&& *id == b.id
&& *name == b.name
&& *layers == b.layers
&& *guides == b.guides
&& *ruler_origin == b.ruler_origin
&& *metadata == b.metadata
&& *paths == b.paths
&& *work_path == b.work_path
&& *clipping_path == b.clipping_path
&& *quick_mask_unselected == b.quick_mask_unselected
&& *layer_comps == b.layer_comps
&& *last_applied_comp == b.last_applied_comp
&& *last_document_state == b.last_document_state
&& *measurement == b.measurement
&& *notes == b.notes
&& *text_styles == b.text_styles
&& *slices == b.slices
&& *variables == b.variables
&& *timeline == b.timeline
&& *file_encoding == b.file_encoding
}
/// Pixels a single history step can have changed between `a` and `b` (the documents on either
@@ -1076,6 +1162,84 @@ fn distribute(s: &mut Session, kind: &str) -> Result<Value> {
/// Reorder one or multiple layers to a single destination in one undoable edit.
///
/// Explicit `layers` are ordered according to the source document, not caller ordering.
/// A transient snapshot for the Layers panel's "Preview on canvas when reordering layers".
/// Uses the same source ordering and insertion semantics as `move_to`, but never edits history.
/// Invalid/self-referential hover targets return None (the committed image stays visible).
pub fn preview_move(doc: &Document, p: &Value, add_copy: bool) -> Option<Document> {
let target = LayerId(p.get("target")?.as_u64()?);
let position = p.get("position")?.as_str()?;
if !matches!(position, "above" | "below" | "into") {
return None;
}
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let ids = if let Some(ids) = p.get("layers") {
ids.as_array()?.iter().map(|v| v.as_u64().map(LayerId)).collect::<Option<Vec<_>>>()?
} else {
vec![LayerId(p.get("layer")?.as_u64()?)]
};
if ids.is_empty() || doc.layer(target).is_none() {
return None;
}
let moved_ids = top_level(doc, &ids);
if moved_ids.is_empty() || moved_ids.iter().any(|id| doc.layer(*id).is_none()) {
return None;
}
if moved_ids.len() == 1 && moved_ids[0] == target && !copy {
return None;
}
let target_path = doc.path_of(target)?;
for id in &moved_ids {
let path = doc.path_of(*id)?;
if target_path.starts_with(&path) && !(copy && *id == target && position != "into") {
return None;
}
}
if position == "into" && !doc.layer(target)?.is_group() {
return None;
}
// The very structural move the drop commits, so the preview shows the same clip groups,
// Background handling and group-depth refusals; a drop `move_to` would refuse has no preview.
let mut preview = doc.clone();
apply_move(&mut preview, &moved_ids, target, position, copy, add_copy).ok()?;
Some(preview)
}
/// The document-only part of `move_to`, shared with [`preview_move`] so the Layers panel's hover
/// preview cannot drift from what the drop commits. Moves (or, with `copy`, duplicates) the
/// top-level `moved_ids` to `pos` (`above`, `below` or `into`) `target`, then applies the
/// group-depth cap, the Background-stays-at-the-bottom rule and the clip-group rules
/// (`clip_group::settle`). Returns the ids of the new copies (empty for a move). On `Err` `doc` is
/// half edited: run it inside `Session::edit` or on a scratch copy.
fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: &str, copy: bool, add_copy: bool) -> Result<Vec<LayerId>> {
const CMD: &str = "layer.moveTo";
let background = crate::background_cmds::background_id(doc);
let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, moved_ids) };
let mut moved = Vec::with_capacity(moved_ids.len());
for &id in moved_ids {
moved.push(if copy { crate::commands::layer_copy(doc, id, add_copy)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
}
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
// Look up the destination AFTER removing every source: its indices may have shifted.
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
if pos == "into" {
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
children.extend(moved);
} else {
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
let siblings = if parent.is_empty() {
&mut doc.layers
} else {
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
};
let at = if pos == "below" { idx } else { idx + 1 };
siblings.splice(at..at, moved);
}
check_group_depth(doc, "Reorder Layer")?;
crate::background_cmds::keep_background_at_bottom(background, doc)?;
crate::clip_group::settle(doc, if copy { &copies } else { moved_ids }, &before, false);
Ok(copies)
}
/// Selected descendants of a moved group remain inside their parent; only top-level
/// selected nodes are detached, and every reference is validated before editing.
pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1133,32 +1297,9 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
}
let original_active = st.active_layer;
let background = crate::background_cmds::background_id(doc);
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| {
let before = if copy { Default::default() } else { crate::clip_group::Before::read(doc, &moved_ids) };
let mut moved = Vec::with_capacity(moved_ids.len());
for &id in &moved_ids {
moved.push(if copy { crate::commands::layer_copy(doc, id)? } else { doc.remove(id).ok_or(EngineError::NoLayer(id))? });
}
let copies: Vec<LayerId> = if copy { moved.iter().map(|l| l.id).collect() } else { Vec::new() };
// Look up the destination AFTER removing every source: its indices may have shifted.
let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?;
if pos == "into" {
let children = doc.layer_at_mut(&path).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target is not a group"))?;
children.extend(moved);
} else {
let (&idx, parent) = path.split_last().ok_or(EngineError::NoLayer(target))?;
let siblings = if parent.is_empty() {
&mut doc.layers
} else {
doc.layer_at_mut(parent).and_then(Layer::children_mut).ok_or_else(|| bad(CMD, "target's parent is not a group"))?
};
let at = if pos == "below" { idx } else { idx + 1 };
siblings.splice(at..at, moved);
}
check_group_depth(doc, "Reorder Layer")?;
crate::background_cmds::keep_background_at_bottom(background, doc)?;
crate::clip_group::settle(doc, if copy { &copies } else { &moved_ids }, &before, false);
let copies = apply_move(doc, &moved_ids, target, pos, copy, add_copy)?;
*active = if copy {
copies.last().copied()
} else if batch.is_some() {
@@ -1281,7 +1422,8 @@ pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> {
}
/// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves
/// into a new group placed where the top-most of them was. Bottom-to-top order is preserved.
/// into a new, collapsed group placed where the top-most of them was. Bottom-to-top order is
/// preserved.
pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
let ids = match crate::commands::explicit_layer(s, p)? {
Some(id) => vec![id],
@@ -1295,7 +1437,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
let ids = top_level(doc, &ids);
let top = *ids.last().ok_or(EngineError::NoLayer(LayerId(0)))?;
let name = name.unwrap_or_else(|| doc.next_layer_name("Group"));
let gid = doc.insert_above(Some(top), Layer::group(name, vec![]));
// Photoshop creates the group collapsed, so the panel shows one row in place of the layers.
let mut group = Layer::group(name, vec![]);
if let LayerContent::Group(g) = &mut group.content {
g.expanded = false;
}
let gid = doc.insert_above(Some(top), group);
let mut children = Vec::with_capacity(ids.len());
for id in &ids {
children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?);
@@ -1406,13 +1553,14 @@ pub fn delete_selected(s: &mut Session) -> Result<Value> {
pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result<Value> {
let sel = selected(s);
let old_active = s.active().and_then(|d| d.active_layer);
let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true);
let (copies, active) = s.edit(label, |doc, active| {
let mut copies = Vec::new();
let mut new_active = None;
for id in top_level(doc, &sel) {
// Match the single-layer duplicate path: Background copies become
// ordinary layers, while normal copies retain their original locks.
let dup = crate::commands::layer_copy(doc, id)?;
let dup = crate::commands::layer_copy(doc, id, add_copy)?;
let nid = doc.insert_above(Some(id), dup);
if !in_place {
crate::artboard_cmds::place_copy(doc, nid)?;
@@ -2417,6 +2565,7 @@ mod tests {
let docu = doc(&s);
let gl = docu.layer(g).unwrap();
assert_eq!(gl.name, "Pair");
assert!(matches!(&gl.content, LayerContent::Group(gr) if !gr.expanded), "created collapsed");
assert_eq!(gl.children().unwrap().iter().map(|l| l.id).collect::<Vec<_>>(), vec![a, c]);
// Root (bottom-to-top): Background, b, Pair (where c was), d.
assert_eq!(docu.layers.iter().map(|l| l.id).skip(1).collect::<Vec<_>>(), vec![b, g, d]);
@@ -2425,8 +2574,10 @@ mod tests {
assert_eq!(doc(&s).layers.len(), 5);
// ⌘G with several selected groups them too.
select_all(&mut s, &[b, d]);
s.execute("layer.groupLayers", json!({})).unwrap();
let g = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
assert_eq!(doc(&s).layers.len(), 4);
// Both create the group collapsed, as Photoshop does (#5571).
assert!(matches!(&doc(&s).layer(g).unwrap().content, LayerContent::Group(gr) if !gr.expanded));
}
#[test]
@@ -2850,6 +3001,7 @@ mod tests {
assert!(s.execute("layer.setProps", json!({"locks": {"nope": true}})).is_err());
}
/// `document.inspect` lists the selected layers and marks them in the layer list.
#[test]
fn inspect_reports_selection() {
let mut s = session(8);
@@ -2861,3 +3013,320 @@ mod tests {
assert_eq!(v["layers"][0]["selected"], true);
}
}
#[cfg(test)]
mod layers_panel_parity_tests {
use super::*;
/// A 24×16 document with three layers, "Test 0" to "Test 2", above the Background; the last one is active.
fn new_session() -> (Session, Vec<LayerId>) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 16})).unwrap();
let ids = (0..3).map(|n| LayerId(s.execute("layer.new.layer", json!({"name": format!("Test {n}")})).unwrap()["layer"].as_u64().unwrap())).collect();
(s, ids)
}
/// Set one Layers panel option in the preferences, as the Panel Options dialog does.
fn option(s: &mut Session, key: &str, enabled: bool) {
s.prefs.edit(|p| {
let val = p.dialogs.entry("ui.layersPanelOptions".into()).or_insert_with(|| json!({}));
if let Some(o) = val.as_object_mut() {
o.insert(key.to_owned(), json!(enabled));
}
});
}
/// The drag preview equals the committed `layer.moveTo` result and never touches the document or its history.
#[test]
fn preview_matches_real_move_and_never_edits_document_or_history() {
let (mut s, ids) = new_session();
let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "above"});
let before = s.active().unwrap().doc.clone();
let undo_before = s.active().unwrap().history.entries().len();
let candidate = preview_move(&before, &params, true).unwrap();
assert_eq!(*before, *s.active().unwrap().doc);
assert_eq!(s.active().unwrap().history.entries().len(), undo_before);
s.execute("layer.moveTo", params).unwrap();
assert_eq!(candidate, *s.active().unwrap().doc, "visual preview must match committed layer order");
}
/// A previewed ⌥-drag copy names its layers like the committed copy, with and without the "copy" suffix option.
#[test]
fn preview_copy_and_no_copy_suffix_option_match_committed_layers() {
let (mut s, ids) = new_session();
option(&mut s, "addCopyToCopiedLayersAndGroups", false);
let params = json!({"layer": ids[0].0, "target": ids[2].0, "position": "below", "copy": true});
let before = s.active().unwrap().doc.clone();
let candidate = preview_move(&before, &params, false).unwrap();
s.execute("layer.moveTo", params).unwrap();
let actual = &s.active().unwrap().doc;
assert_eq!(candidate.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), actual.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>());
assert_eq!(actual.layer_count(), before.layer_count() + 1);
assert_eq!(actual.layers.iter().filter(|l| l.name == "Test 0").count(), 2);
let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap();
let duplicate = LayerId(d["layer"].as_u64().unwrap());
assert_eq!(s.active().unwrap().doc.layer(duplicate).unwrap().name, "Test 1");
option(&mut s, "addCopyToCopiedLayersAndGroups", true);
let d = s.execute("layer.duplicate", json!({"layer": ids[1].0})).unwrap();
assert!(s.active().unwrap().doc.layer(LayerId(d["layer"].as_u64().unwrap())).unwrap().name.contains("copy"));
}
/// Drops the command refuses (or ignores) give no preview and leave the document alone; a multi-layer preview follows the committed stack order.
#[test]
fn preview_rejects_invalid_drop_unchanged() {
let (mut s, ids) = new_session();
let before = s.active().unwrap().doc.clone();
// Dropping a layer on itself is a no-op for the command, the others are errors.
for (p, command_rejects) in [
(json!({"layer": ids[1].0, "target": ids[1].0, "position": "above"}), false),
(json!({"layer": ids[1].0, "target": 999999, "position": "below"}), true),
(json!({"layer": ids[1].0, "target": ids[2].0, "position": "into"}), true),
(json!({"layer": ids[1].0, "target": ids[2].0, "position": "invalid"}), true),
] {
assert!(preview_move(&before, &p, true).is_none(), "{p}");
let result = s.execute("layer.moveTo", p.clone());
if command_rejects {
assert!(result.is_err(), "{p}");
} else {
assert_eq!(result.unwrap(), Value::Null, "{p}");
}
assert_eq!(*before, *s.active().unwrap().doc, "{p}");
}
// Multi-layer preview and commit follow the same stack order regardless of params order.
let p = json!({"layers": [ids[2].0, ids[0].0], "target": ids[1].0, "position": "below"});
let candidate = preview_move(&before, &p, true).unwrap();
s.execute("layer.moveTo", p).unwrap();
assert_eq!(candidate, *s.active().unwrap().doc);
}
/// A 16×16 document holding `spec` (bottom to top, `*` = clipped) above its Background. Every
/// layer is a translucent, differently placed rectangle, so the stack order and the clip
/// groups both show in the composite.
fn clip_stack(spec: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
for (n, name) in spec.split_whitespace().enumerate() {
let id = LayerId(s.execute("layer.new.layer", json!({"name": name.trim_end_matches('*')})).unwrap()["layer"].as_u64().unwrap());
let n = n as i32;
let colour = [(n % 3) as f32 * 0.5, ((n + 1) % 3) as f32 * 0.5, ((n + 2) % 3) as f32 * 0.5, 0.6];
s.edit("paint", |doc, _| {
let layer = doc.layer_mut(id).unwrap();
layer.clipped = name.ends_with('*');
layer.surface_mut().unwrap().fill_rect(Rect::new(n * 2, 0, n * 2 + 10, 16), &colour);
Ok(())
})
.unwrap();
}
s
}
/// Every layer of `doc` from the bottom, `*` marking the clipped ones.
fn stack_text(doc: &Document) -> String {
doc.layers.iter().map(|l| format!("{}{}", l.name, if l.clipped { "*" } else { "" })).collect::<Vec<_>>().join(" ")
}
/// The id of the layer called `name`, searching the whole layer tree.
fn id_of(s: &Session, name: &str) -> u64 {
s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id.0
}
/// The preview of `params` and the committed `layer.moveTo` show the same stack (names, clip
/// flags) and the same composite, and both are `want`.
fn assert_preview_is_commit(mut s: Session, params: Value, want: &str) {
let before = s.active().unwrap().doc.clone();
let preview = preview_move(&before, &params, true).expect("the drop has a preview");
s.execute("layer.moveTo", params.clone()).unwrap();
let committed = &s.active().unwrap().doc;
assert_eq!(stack_text(&preview), want, "preview of {params}");
assert_eq!(stack_text(committed), want, "commit of {params}");
assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px, "composite of {params}");
}
/// Previewed moves join and release clipping groups exactly as the committed reorder does, copies included.
#[test]
fn preview_follows_clip_groups_like_the_committed_reorder() {
// The fixtures of `clip_group::reorder_joins_and_leaves_groups`.
for (spec, layers, target, pos, want) in [
// A layer dropped into a clip group joins it.
("B C1* C2* U", vec!["U"], "C1", "above", "Background B C1* U* C2*"),
("B C1* C2* U V", vec!["U", "V"], "C1", "above", "Background B C1* U* V* C2*"),
// A clipped layer dropped off its group is released.
("B C1* C2* U", vec!["C1"], "U", "above", "Background B C2* U C1"),
// A base moved away releases the layers clipped to it.
("B C1* C2* U", vec!["B"], "U", "above", "Background C1 C2 U B"),
// A base moved up into its own group joins it, and the layers it left are released.
("B C1* C2*", vec!["B"], "C1", "above", "Background C1 B* C2"),
("X B C1* C2* U", vec!["B", "C1"], "U", "above", "Background X C2 U B C1*"),
] {
let s = clip_stack(spec);
let ids: Vec<u64> = layers.iter().map(|n| id_of(&s, n)).collect();
let t = id_of(&s, target);
assert_preview_is_commit(s, json!({"layers": ids, "target": t, "position": pos}), want);
}
// An option-drag copy joins the clip group it lands in too.
let s = clip_stack("B C1* C2* U");
let (u, c1) = (id_of(&s, "U"), id_of(&s, "C1"));
assert_preview_is_commit(s, json!({"layer": u, "target": c1, "position": "above", "copy": true}), "Background B C1* U copy* C2* U");
}
/// Copying the locked Background in a preview gives the same unlocked copy, joining the clip group it lands in, as the command.
#[test]
fn preview_copies_the_background_like_the_committed_copy() {
let mut s = clip_stack("A B*");
let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A"));
let before = s.active().unwrap().doc.clone();
assert_ne!(before.layer(bg).unwrap().locks, Default::default(), "the Background is locked");
let p = json!({"layer": bg.0, "target": a, "position": "above", "copy": true});
let preview = preview_move(&before, &p, true).unwrap();
s.execute("layer.moveTo", p).unwrap();
let committed = &s.active().unwrap().doc;
// The copy is an ordinary, unlocked layer that joins the clip group it lands in.
assert_eq!(stack_text(committed), "Background A Background copy* B*");
assert_eq!(stack_text(&preview), stack_text(committed));
for doc in [&preview, committed] {
assert_eq!(doc.layers.get(2).unwrap().locks, Default::default());
assert!(doc.layers.first().unwrap().locks != Default::default());
}
assert_eq!(photocraft_compose::flatten(&preview).px, photocraft_compose::flatten(committed).px);
}
/// A drop that would move the Background off the bottom has no preview, as the command refuses it.
#[test]
fn preview_refuses_what_would_leave_the_background_off_the_bottom() {
let mut s = clip_stack("A B");
let (bg, a) = (s.active().unwrap().doc.background().unwrap().id, id_of(&s, "A"));
let before = s.active().unwrap().doc.clone();
for p in [
json!({"layer": a, "target": bg.0, "position": "below"}),
json!({"layer": bg.0, "target": bg.0, "position": "below", "copy": true}),
json!({"layer": a, "target": bg.0, "position": "below", "copy": true}),
] {
assert!(preview_move(&before, &p, true).is_none(), "{p}");
assert!(s.execute("layer.moveTo", p.clone()).is_err(), "{p}");
assert_eq!(*before, *s.active().unwrap().doc, "{p}");
}
}
/// The UI runs commands through `start`; Expand New Effects applies there as well as to `execute`.
#[test]
fn expand_new_effects_applies_to_ui_start_dispatch_too() {
let (mut s, ids) = new_session();
option(&mut s, "expandNewEffects", false);
let target = ids[0];
let started = s.start("layer.layerStyle.dropShadow", json!({"layer": target.0})).unwrap();
assert!(matches!(started, crate::jobs::Started::Done(_)));
assert!(s.active().unwrap().fx_collapsed.contains(&target));
}
/// New Solid Color and Gradient fill layers get the default mask only while Use Default Masks on Fill Layers is on.
#[test]
fn fill_layers_respect_use_default_masks_option() {
let (mut s, _) = new_session();
option(&mut s, "useDefaultMasksOnFillLayers", false);
let new = s.execute("layer.newFillLayer.solidColor", json!({"color": "#112233"})).unwrap();
let id = LayerId(new["layer"].as_u64().unwrap());
assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_none());
option(&mut s, "useDefaultMasksOnFillLayers", true);
let new = s.execute("layer.newFillLayer.gradient", json!({})).unwrap();
let id = LayerId(new["layer"].as_u64().unwrap());
assert!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some());
}
/// With the option off only the default reveal-all mask goes away: an active selection still
/// becomes the layer mask and an explicit path still becomes the vector mask (path first).
#[test]
fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() {
const KINDS: [(&str, &str); 3] = [
("layer.newFillLayer.solidColor", r##"{"color": "#112233"}"##),
("layer.newFillLayer.gradient", "{}"),
("layer.newFillLayer.pattern", r#"{"pattern": "Checkerboard"}"#),
];
let path = json!({"subpaths": [{"closed": true, "knots": [[2, 2], [10, 2], [10, 10], [2, 10]]}]});
let (mut s, _) = new_session();
option(&mut s, "useDefaultMasksOnFillLayers", false);
// Creates the fill layer; returns its raster mask and whether it has a vector mask.
let create = |s: &mut Session, cmd: &str, params: &str, path: Option<&Value>| {
let mut p: Value = serde_json::from_str(params).unwrap();
if let Some(path) = path {
p["path"] = path.clone();
}
let id = LayerId(s.execute(cmd, p).unwrap()["layer"].as_u64().unwrap());
let l = s.active().unwrap().doc.layer(id).unwrap();
(l.mask.clone(), l.vector_mask.is_some())
};
for (cmd, params) in KINDS {
let (mask, vector) = create(&mut s, cmd, params, None);
assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all");
}
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
for (cmd, params) in KINDS {
let (mask, vector) = create(&mut s, cmd, params, None);
let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask"));
assert!(!vector, "{cmd}");
assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals");
assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides");
}
// An explicit path wins over the selection, as with the option on.
for selected in [true, false] {
if !selected {
s.execute("select.deselect", json!({})).unwrap();
}
for (cmd, params) in KINDS {
let (mask, vector) = create(&mut s, cmd, params, Some(&path));
assert!(vector, "{cmd}: the explicit path becomes the vector mask (selection: {selected})");
assert!(mask.is_none(), "{cmd}: no raster mask next to the vector mask (selection: {selected})");
}
}
}
/// Whether new effects start expanded is view state: it adds no history step, and editing an existing effect keeps its disclosure.
#[test]
fn expand_new_effects_is_view_state_and_keeps_edit_history_clean() {
let (mut s, ids) = new_session();
option(&mut s, "expandNewEffects", false);
let target = ids[0];
let history = s.active().unwrap().history.entries().len();
s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 2})).unwrap();
assert_eq!(s.active().unwrap().history.entries().len(), history + 1);
assert!(s.active().unwrap().fx_collapsed.contains(&target));
option(&mut s, "expandNewEffects", true);
let other = ids[1];
s.execute("layer.layerStyle.stroke", json!({"layer": other.0, "size": 2})).unwrap();
assert!(!s.active().unwrap().fx_collapsed.contains(&other));
// Editing the existing effect must not unexpectedly change its disclosure.
s.execute("layer.layerStyle.dropShadow", json!({"layer": target.0, "size": 5})).unwrap();
assert!(s.active().unwrap().fx_collapsed.contains(&target));
}
/// The disclosure follows the layer the command changes, however the command names it: a
/// layer given by name is not the active one.
#[test]
fn expand_new_effects_follows_a_layer_named_by_its_name() {
let (mut s, ids) = new_session();
option(&mut s, "expandNewEffects", false);
let (named, active) = (ids[0], ids[2]);
assert_eq!(s.active().unwrap().active_layer, Some(active));
s.execute("layer.layerStyle.dropShadow", json!({"layer": "Test 0", "size": 2})).unwrap();
assert!(s.active().unwrap().doc.layer(named).is_some_and(|l| !l.effects.items.is_empty()));
assert!(s.active().unwrap().fx_collapsed.contains(&named), "the named layer's new effects start collapsed");
assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed");
}
/// Transfer Effects changes the layer it moves the effects to, so that is the layer whose
/// disclosure follows the option, not the active layer or the one the effects came from.
#[test]
fn expand_new_effects_follows_the_transfer_destination() {
let (mut s, ids) = new_session();
let (from, to, active) = (ids[0], ids[1], ids[2]);
s.execute("layer.layerStyle.dropShadow", json!({"layer": from.0, "size": 2})).unwrap();
option(&mut s, "expandNewEffects", false);
assert_eq!(s.active().unwrap().active_layer, Some(active));
s.execute("layer.layerStyle.transferEffects", json!({"from": from.0, "to": to.0, "copy": true})).unwrap();
assert!(s.active().unwrap().doc.layer(to).is_some_and(|l| !l.effects.items.is_empty()));
assert!(s.active().unwrap().fx_collapsed.contains(&to), "the destination's new effects start collapsed");
assert!(!s.active().unwrap().fx_collapsed.contains(&active), "the active layer is not the one changed");
}
}
+1
View File
@@ -118,6 +118,7 @@ mod tests {
s.execute("layer.select", json!({"layer": c.0, "mode": "add"})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
let group = s.active().unwrap().doc.layers.iter().find(|l| l.is_group()).unwrap().id;
s.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true})).unwrap();
let d = id(&mut s, "layer.new.layer", json!({"name": "D"}));
(s, [bg, a, group, b, c, d])
}
+56 -8
View File
@@ -86,19 +86,19 @@ pub fn effect_defaults(kind: &str) -> Value {
json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0})
}
"outerGlow" => {
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
}
"innerGlow" => {
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0})
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "source": "edge", "choke": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
}
"stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}),
"stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}),
"colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}),
"gradientOverlay" => {
json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100})
}
"patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}),
"bevelEmboss" => {
json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "glossAntiAlias": false, "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
}
"satin" => {
json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true})
@@ -510,6 +510,7 @@ fn fresh_effect(kind: &str) -> Option<Effect> {
size: 1.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::BLACK),
overprint: false,
}),
"colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) },
"gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false },
@@ -544,6 +545,7 @@ fn fresh_effect(kind: &str) -> Option<Effect> {
altitude: 30.0,
use_global_light: true,
gloss_contour: Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
@@ -693,6 +695,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) {
_ => StrokePosition::Outside,
};
}
set_bool(p, "overprint", &mut s.overprint);
if p.get("from").is_some() {
s.paint = FxPaint::Gradient(gradient(p));
} else if let Some(c) = color_of(p, "color") {
@@ -792,6 +795,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) {
bv.use_global_light = x;
}
set_contour(p, "glossContour", &mut bv.gloss_contour);
set_bool(p, "glossAntiAlias", &mut bv.gloss_anti_alias);
// The Contour element: `contour` turns it on/off; its curve, Range and
// Anti-aliased edit the element in place, so an imported curve survives.
match b_of(p, "contour") {
@@ -1002,17 +1006,17 @@ pub fn specs() -> Vec<CommandSpec> {
style_cmd!(
"outerGlow",
"Outer Glow…",
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
),
style_cmd!(
"innerGlow",
"Inner Glow…",
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
),
style_cmd!(
"stroke",
"Stroke…",
r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"##
r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"overprint":bool,"add":bool}"##
),
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
style_cmd!(
@@ -1028,7 +1032,7 @@ pub fn specs() -> Vec<CommandSpec> {
style_cmd!(
"bevelEmboss",
"Bevel & Emboss…",
r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##
r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"glossAntiAlias":bool,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##
),
style_cmd!(
"satin",
@@ -1144,6 +1148,48 @@ mod tests {
assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true));
}
/// #6698 review: a glow's Range runs 1..100 %, as its slider and Photoshop's do. A 0 is
/// held at the 1 % floor, and 100 is kept.
#[test]
fn glow_range_is_held_to_one_through_one_hundred() {
for kind in ["outerGlow", "innerGlow"] {
for (sent, kept) in [(0.0, 0.01), (-5.0, 0.01), (1.0, 0.01), (50.0, 0.5), (100.0, 1.0), (250.0, 1.0)] {
let mut s = session();
s.execute(&format!("layer.layerStyle.{kind}"), json!({"range": sent})).unwrap();
let range = match &effects(&s)[0] {
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.range,
other => panic!("{other:?}"),
};
assert!((range - kept).abs() < 1e-6, "{kind} range {sent} kept as {range}, not {kept}");
}
}
let doc = |id: &str| specs().into_iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default();
assert!(doc("layer.layerStyle.outerGlow").contains(r#""range":1..100=50"#));
assert!(doc("layer.layerStyle.innerGlow").contains(r#""range":1..100=50"#));
}
#[test]
fn bevel_gloss_anti_alias_is_a_param() {
let mut s = session();
s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap();
let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() };
assert!(b.gloss_anti_alias);
s.execute("layer.layerStyle.bevelEmboss", json!({"glossAntiAlias": false})).unwrap();
let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() };
assert!(!b.gloss_anti_alias);
}
#[test]
fn stroke_overprint_is_a_param() {
let mut s = session();
s.execute("layer.layerStyle.stroke", json!({"size": 4, "overprint": true})).unwrap();
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
assert!(st.overprint);
s.execute("layer.layerStyle.stroke", json!({"overprint": false})).unwrap();
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
assert!(!st.overprint);
}
#[test]
fn every_style_command_adds_its_effect() {
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] {
@@ -1356,6 +1402,7 @@ mod tests {
size: 7.0,
position: StrokePosition::Outside,
paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }),
overprint: false,
});
let r = s
.execute(
@@ -1392,6 +1439,7 @@ mod tests {
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::BLACK),
overprint: false,
});
for p in [
json!({}),
+51 -5
View File
@@ -211,6 +211,8 @@ pub struct DocState {
/// Increments on every change; UIs re-render when it moves.
pub revision: u64,
pub saved_revision: u64,
/// Native save cache shared with this document's workers, released with the editing state.
pub pcraft_cache: photocraft_io::PcraftExportCache,
/// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those.
pub view_revision: u64,
/// Area changed by the latest revision (None = assume everything changed).
@@ -275,6 +277,7 @@ impl DocState {
source_read_only: false,
revision: 1,
saved_revision: 1,
pcraft_cache: Default::default(),
view_revision: 0,
last_damage: None,
coalesce: None,
@@ -304,6 +307,21 @@ impl DocState {
pub fn is_dirty(&self) -> bool {
self.revision != self.saved_revision
}
/// Whether the step from `before` to the current document leaves a clean document clean:
/// it changed nothing but the selection (#6865). Making, changing or dropping a selection
/// (marquee, lasso, Magic Wand, Quick / Object Selection, Select › …) is a history step but
/// not an edit, so it neither marks the file modified nor makes closing ask to save.
pub(crate) fn keeps_clean(&self, before: &Document) -> bool {
self.saved_revision == self.revision && layer_multi_cmds::selection_only_change(before, &self.doc)
}
/// Move to the next revision; `keep_clean` (from [`Self::keeps_clean`]) moves the saved
/// revision with it.
pub(crate) fn bump_revision(&mut self, keep_clean: bool) {
self.revision += 1;
if keep_clean {
self.saved_revision = self.revision;
}
}
/// The targeted layers, as history stores them with each state.
fn layer_target(&self) -> LayerTarget {
LayerTarget { active: self.active_layer, selected: self.selected_layers.clone() }
@@ -857,6 +875,7 @@ impl Session {
// duplicate or a paste), and everything when that can't be bounded. A command may still
// report its own, tighter damage after this.
let damage = layer_multi_cmds::step_damage(&before, &st.doc);
let keep_clean = st.keeps_clean(&before);
let key = self.coalesce_request.clone();
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
let layers = st.layer_target();
@@ -868,7 +887,7 @@ impl Session {
st.history.set_current_layers(layers);
}
st.coalesce = key;
st.revision += 1;
st.bump_revision(keep_clean);
st.last_damage = damage;
self.samplers_removed += samplers_removed;
if damage.is_none_or(|d| !d.is_empty()) {
@@ -899,6 +918,31 @@ impl Session {
Ok(())
}
/// Put back a layer selection (active layer, selected layers, anchor) that a step moved, such
/// as the undo ending an empty Type Mask, which restores the selection from before it. `None`
/// is no layer selected. Selected layers that are gone are dropped; an active layer that is
/// gone is an error and changes nothing. Like [`Self::select_layer`], not an edit.
pub fn restore_layer_selection(&mut self, active: Option<LayerId>, selected: &[LayerId], anchor: Option<LayerId>) -> Result<()> {
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
if let Some(id) = active
&& st.doc.layer(id).is_none()
{
return Err(EngineError::NoLayer(id));
}
st.active_layer = active;
st.selected_layers = selected.to_vec();
st.layer_anchor = anchor.filter(|_| active.is_some());
fix_selection(st);
channel_cmds::fix_view(st);
let clean = st.saved_revision == st.revision;
st.revision += 1;
if clean {
st.saved_revision = st.revision;
}
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
Ok(())
}
/// Re-renders the pixels of type layers whose font arrived after they were drawn (the web
/// build fetches served fonts on demand, `photocraft_text::served`). The layers' model is
/// unchanged, so this is no history step and leaves a clean document clean. Unknown documents
@@ -969,9 +1013,10 @@ impl Session {
// Pixels this step can have touched, so the canvas recomposites only that
// (it recomposited everything before).
let damage = layer_multi_cmds::step_damage(&d, &st.doc);
st.doc = d;
let before = std::mem::replace(&mut st.doc, d);
let keep_clean = st.keeps_clean(&before);
restore_target(st, layers);
st.revision += 1;
st.bump_revision(keep_clean);
st.last_damage = damage;
true
}
@@ -991,9 +1036,10 @@ impl Session {
Arc::make_mut(&mut d).name.clone_from(&st.doc.name);
}
let damage = layer_multi_cmds::step_damage(&st.doc, &d);
st.doc = d;
let before = std::mem::replace(&mut st.doc, d);
let keep_clean = st.keeps_clean(&before);
restore_target(st, layers);
st.revision += 1;
st.bump_revision(keep_clean);
st.last_damage = damage;
true
}
+19 -3
View File
@@ -396,6 +396,10 @@ fn color_table(s: &mut Session, p: &Value) -> Result<Value> {
/// Bitmap's Output resolution range (ppi): what a new document may have.
const BITMAP_RESOLUTION: std::ops::RangeInclusive<f64> = 1.0..=crate::document_preset_cmds::MAX_RESOLUTION;
/// Bytes per pixel of the Bitmap conversion's full-canvas working buffers at their peak (see
/// `bitmap`), on top of the document itself.
const BITMAP_WORK_BYTES_PER_PX: u64 = 40;
/// Bitmap's Custom Pattern: a pattern's threshold map (see
/// [`quantize::custom_pattern_thresholds`]) with its width and height.
fn custom_pattern_map(s: &Session, cmd: &str, p: &Value) -> Result<(Vec<f32>, usize, usize)> {
@@ -427,11 +431,23 @@ fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
let (nw, nh) = if (output - input).abs() < 1e-6 {
(ow, oh)
} else {
let side = |v: u32| (f64::from(v) * output / input).round().clamp(1.0, 300_000.0) as u32;
(side(ow), side(oh))
// Keeping the print size can ask for more pixels than a document may have: refuse that
// rather than clamp, which would change the print size.
let max = crate::document_preset_cmds::MAX_DIMENSION;
let side = |v: u32| {
let n = (f64::from(v) * output / input).round().max(1.0);
if n > f64::from(max) {
return Err(bad(CMD, format!("{n} px across at {output} pixels/inch is more than {max} px; choose a lower Output resolution")));
}
Ok(n as u32)
};
(side(ow)?, side(oh)?)
};
if (nw, nh) != (ow, oh) {
crate::image_cmds::check_resample_budget(CMD, &d.doc, nw, nh, ", or a lower Output resolution")?;
// Besides the resampled document, the conversion holds an RGBA f32 composite (16 B/px; 32
// while it is laid over white), the gray plane (4) and the RGBA f32 output (16) at the new
// size: budget them all before allocating, not just the resample.
crate::image_cmds::check_working_budget(CMD, &d.doc, nw, nh, BITMAP_WORK_BYTES_PER_PX, ", or a lower Output resolution")?;
}
let dpi = output as f32;
let mut custom = None;
+31
View File
@@ -470,6 +470,37 @@ fn bitmap_rejects_bad_resolutions_and_patterns_without_editing_the_document() {
assert_eq!((doc(&s).size.width, doc(&s).size.height, doc(&s).resolution_dpi), (1, 1, 1.0));
}
#[test]
fn bitmap_output_resolution_budgets_the_whole_conversion_not_just_the_resample() {
// 100 x 100 px at 1 ppi to 200 ppi is 20000 x 20000 px: 400 MP. Resampling an 8/16-bit gray
// document to it fits the resample budget (under 2 GB), but the conversion's f32 working
// buffers at that size (~40 B/px, 16 GB) don't: it is refused before anything is allocated.
for depth in [8, 16] {
let mut s = ramp(100, 100, depth, 1.0);
let d = &s.active().unwrap().doc;
assert!(crate::image_cmds::check_resample_budget("t", d, 20_000, 20_000, "").is_ok(), "{depth}");
let before = s.active().unwrap().history.past_len();
let e = s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 200})).unwrap_err().to_string();
assert!(e.contains("megapixels") && e.contains("Output resolution"), "{depth}: {e}");
assert_eq!(s.active().unwrap().history.past_len(), before, "{depth}");
let d = doc(&s);
assert_eq!((d.mode, d.size.width, d.resolution_dpi), (ColorMode::Grayscale, 100, 1.0), "{depth}");
// A side past the 300,000 px document limit is refused, not clamped (that would change
// the print size): 20000 px at 72 ppi is 333,333 px at 1200 ppi.
let mut wide = ramp(20_000, 1, depth, 72.0);
let e = wide.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 1200})).unwrap_err().to_string();
assert!(e.contains("300000 px"), "{depth}: {e}");
assert_eq!((doc(&wide).mode, doc(&wide).size.width), (ColorMode::Grayscale, 20_000), "{depth}");
// 18000 px to 300,000 px exactly is allowed.
let mut edge = ramp(18_000, 1, depth, 72.0);
edge.execute("image.mode.bitmap", json!({"method": "threshold", "resolution": 1200})).unwrap();
assert_eq!(doc(&edge).size.width, 300_000, "{depth}");
// A size that fits is still converted.
s.execute("image.mode.bitmap", json!({"method": "diffusion", "resolution": 3})).unwrap();
assert_eq!((doc(&s).size.width, doc(&s).mode), (300, ColorMode::Bitmap), "{depth}");
}
}
#[test]
fn duotone_inks_and_display() {
let mut s = session(16, 8, 8, "gray");
+12 -3
View File
@@ -317,23 +317,32 @@ pub(crate) fn placement(cmd: &str, p: &Value) -> Result<(f32, f32, bool, (f32, f
/// The edit of `layer.newFillLayer.pattern`: a Pattern Fill layer of `pat` placed as `p` asks,
/// above `active`. The Pattern Fill dialog previews it on a copy of the document.
pub(crate) fn insert_fill_layer(doc: &mut Document, active: &mut Option<photocraft_doc::LayerId>, pat: &Pattern, p: &Value) -> Result<photocraft_doc::LayerId> {
pub(crate) fn insert_fill_layer(
doc: &mut Document,
active: &mut Option<photocraft_doc::LayerId>,
pat: &Pattern,
p: &Value,
default_mask: bool,
) -> Result<photocraft_doc::LayerId> {
let (scale, angle, link, phase) = placement("layer.newFillLayer.pattern", p)?;
ensure_in_doc(doc, pat);
let fill = Fill::Pattern { name: pat.name.clone(), scale, id: pat.id.clone(), angle, link, phase };
let mut l = Layer::new(doc.next_layer_name("Pattern Fill"), LayerContent::Fill(fill));
crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern")?;
crate::commands::mask_new_layer(doc, &mut l, p, "layer.newFillLayer.pattern", default_mask)?;
let id = doc.insert_above(*active, l);
*active = Some(id);
Ok(id)
}
/// `layer.newFillLayer.pattern`: a Pattern Fill layer above the active one, masked as the Layers panel's Use Default Masks on Fill Layers option says.
fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
let pat = resolve_param(s, "layer.newFillLayer.pattern", p)?;
let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p))?;
let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
let id = s.edit("New Pattern Fill Layer", |doc, active| insert_fill_layer(doc, active, &pat, p, default_mask))?;
Ok(json!({"layer": id.0, "pattern": pat.id}))
}
/// `brush.texturePattern`: use a pattern as the brush texture, converted to grey as the luminance of its composite over white.
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
let pat = resolve_param(s, "brush.texturePattern", p)?;
let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten();
+37 -1
View File
@@ -124,13 +124,15 @@ pub fn preview(s: &Session, cmd: &str, p: &Value) -> Result<Document> {
CREATE => {
let pat = crate::pattern_cmds::resolve_param(s, CREATE, p)?;
let mut active = st.active_layer;
crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p)?;
let default_mask = crate::commands::layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
crate::pattern_cmds::insert_fill_layer(&mut doc, &mut active, &pat, p, default_mask)?;
}
other => return Err(EngineError::BadParams { cmd: other.into(), msg: "no Pattern Fill preview for this command".into() }),
}
Ok(doc)
}
/// Register `layer.fill.pattern.set` (the Pattern Fill dialog's command), journaled like any other edit.
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: SET,
@@ -216,6 +218,40 @@ mod tests {
assert!(preview(&s, "edit.fill", &json!({})).is_err());
}
/// At 8, 16 and 32 bits the Pattern Fill preview and the committed layer carry the default mask only while the option is on, and creating one is one undoable step.
#[test]
fn create_preview_and_commit_honor_default_masks_option() {
for depth in [8, 16, 32] {
for default_mask in [false, true] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap();
s.edit_prefs(|prefs| {
prefs.dialogs.insert("ui.layersPanelOptions".into(), json!({"useDefaultMasksOnFillLayers": default_mask}));
});
let before = s.active().unwrap().doc.clone();
let count = s.active().unwrap().history.entries().len();
let p = json!({"pattern": "Grid", "scale": 50});
let shown = preview(&s, CREATE, &p).unwrap();
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
assert_eq!(s.active().unwrap().history.entries().len(), count);
let previewed = shown.layers.iter().find(|l| matches!(l.content, LayerContent::Fill(Fill::Pattern { .. }))).unwrap();
assert_eq!(previewed.mask.is_some(), default_mask);
if let Some(mask) = &previewed.mask {
assert_eq!(mask.surface.format(), photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, shown.depth, false));
}
let id = LayerId(s.execute(CREATE, p).unwrap()["layer"].as_u64().unwrap());
let committed = s.active().unwrap().doc.layer(id).unwrap();
assert_eq!(committed.mask, previewed.mask);
assert_eq!(committed.content, previewed.content);
s.undo();
assert_eq!(*s.active().unwrap().doc, *before);
s.redo();
assert_eq!(s.active().unwrap().doc.layer(id).unwrap().mask.is_some(), default_mask);
}
}
}
/// Malformed or out-of-range params, and missing layers or patterns, are errors in both the command and its preview, never panics.
#[test]
fn bad_params_and_targets_are_errors_not_panics() {
let (mut s, id) = session(8);
@@ -371,6 +371,10 @@ mod tests {
#[test]
fn perspective_crop_rejects_bad_quads() {
let mut s = skewed_ramp(SampleType::U8, &[[0.0, 0.0], [160.0, 0.0], [160.0, 120.0], [0.0, 120.0]], 160.0, 120.0);
// A rejected crop leaves the Color Sampler points and the removed count alone.
let samplers = vec![[5.0, 5.0], [150.0, 110.0]];
s.active_mut().unwrap().color_samplers = samplers.clone();
s.samplers_removed = 2;
for p in [
json!({}),
json!({"corners": [[0, 0], [10, 0], [10, 10]]}),
@@ -388,5 +392,7 @@ mod tests {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert_eq!(s.active().unwrap().doc.size, Size::new(160, 120));
assert_eq!(s.active().unwrap().color_samplers, samplers);
assert_eq!(s.take_samplers_removed(), 2);
}
}
+41 -9
View File
@@ -239,16 +239,21 @@ pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], sel
let fmt = s.format();
// Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept).
let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32);
let solid = opaque.then(|| {
let mut enc = [0.0f32; 8];
let n = photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc);
s.solid_tile(enc.get(..n).unwrap_or(&[]))
});
let mut enc = [0.0f32; 8];
let enc_len = opaque.then(|| photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc));
let solid = enc_len.map(|n| s.solid_tile(enc.get(..n).unwrap_or(&[])));
let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone()));
edit_tiles(s, area, selection, "filling pixels", whole, |k, px| {
if k <= 0.0 {
return false;
}
if k >= 1.0
&& let Some(m) = enc_len
&& let Some(src) = enc.get(..px.len().min(m))
{
px.copy_from_slice(src);
return true;
}
fill_pixel(&fmt, px, color, k, lock_transparency);
true
})
@@ -294,9 +299,31 @@ pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&
px.fill(0.0);
}
true
})?;
s.prune();
Ok(())
})
}
/// Take the coverage a floating selection lifts out of `s` over `area`: each pixel loses its
/// selection coverage k of alpha, down to none, so a selection made from the layer's own
/// transparency leaves nothing behind (#7655), where [`try_clear_surface`] would leave a ghost.
pub(crate) fn try_lift_coverage(s: &mut Surface, area: Rect, selection: &Surface) -> crate::Result<()> {
let fmt = s.format();
let n = fmt.channels();
if !fmt.alpha {
return Ok(());
}
let empty_default = s.default_bytes().iter().all(|&b| b == 0);
let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop);
edit_tiles(s, area, Some(selection), "lifting pixels", whole, |k, px| {
if k <= 0.0 {
return false;
}
let Some(a) = px.get_mut(n - 1) else { return false };
*a = (*a - k).max(0.0);
if *a <= 0.0 {
px.fill(0.0);
}
true
})
}
/// A whole-tile shortcut of [`edit_tiles`].
@@ -331,6 +358,7 @@ fn edit_tiles(
coords.extend(area.tiles());
// Untouched pixels read as the default where the surface has no tile.
let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None };
let dp = s.default_bytes();
let surf = &*s;
let edits = coords
.into_par_iter()
@@ -371,7 +399,11 @@ fn edit_tiles(
}
}
}
Ok(out.map(|t| (tc, Some(Arc::new(t)))))
Ok(match out {
Some(t) if bpp > 0 && t.bytes().chunks_exact(bpp).all(|p| p == dp) => current.map(|_| (tc, None)),
Some(t) => Some((tc, Some(Arc::new(t)))),
None => None,
})
})
.collect::<crate::Result<Vec<_>>>()?;
for (tc, tile) in edits.into_iter().flatten() {
+20 -4
View File
@@ -514,11 +514,14 @@ impl Default for Export {
}
}
/// [`Performance::memory_usage_mb`]'s default.
pub const DEFAULT_MEMORY_USAGE_MB: u32 = 8192;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Performance {
/// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the
/// oldest states are dropped beyond it).
/// oldest states are dropped beyond it), and the result of one transform.
pub memory_usage_mb: u32,
/// Undo steps kept per document (History panel states).
pub history_states: u32,
@@ -573,9 +576,10 @@ impl Performance {
}
impl Default for Performance {
/// What a new install starts with, [`DEFAULT_MEMORY_USAGE_MB`] of Memory Usage among it.
fn default() -> Self {
Self {
memory_usage_mb: 8192,
memory_usage_mb: DEFAULT_MEMORY_USAGE_MB,
history_states: 50,
cache_levels: 4,
cache_tile_size: 8192,
@@ -1487,7 +1491,15 @@ pub fn normalize_shortcut(s: &str) -> Option<String> {
Some(s)
}
/// Key names spelled with more than one capital (egui's `Key::name`): Title case would turn
/// `PageUp` into `Pageup`, which no key matches, so a binding saved that way never fired (#6827).
const MULTI_WORD_KEYS: [&str; 8] =
["PageUp", "PageDown", "OpenBracket", "CloseBracket", "OpenCurlyBracket", "CloseCurlyBracket", "BrowserBack", "IntlBackslash"];
fn capitalize(s: &str) -> String {
if let Some(k) = MULTI_WORD_KEYS.iter().find(|k| k.eq_ignore_ascii_case(s)) {
return (*k).to_string();
}
let mut c = s.chars();
match c.next() {
Some(f) if f.is_ascii_alphabetic() => {
@@ -1519,7 +1531,8 @@ pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
/// Photoshop's second default keys (#3665): (command, key). Each command keeps its main key
/// (the one its menu shows) and answers these as well, as in Photoshop's default set: the
/// legacy function keys F2 Cut, F3 Copy, F4 Paste and Shift+F7 Inverse; Shift+Backspace
/// (Shift+Delete on a Mac) for Fill; and ⌘+ beside ⌘= for Zoom In.
/// (Shift+Delete on a Mac) for Fill; ⌘+ beside ⌘= for Zoom In; and off the Mac, Ctrl+F4 beside
/// Ctrl+W for Close (Windows' close-document key; macOS keeps Ctrl+F4 for window focus).
pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
("edit.cut", "F2"),
("edit.copy", "F3"),
@@ -1527,6 +1540,8 @@ pub const ALTERNATE_SHORTCUTS: &[(&str, &str)] = &[
("edit.fill", "Shift+Backspace"),
("select.inverse", "Shift+F7"),
("view.zoomIn", "Cmd++"),
#[cfg(not(target_os = "macos"))]
("file.close", "Cmd+F4"),
];
/// A command's default alternate keys ([`ALTERNATE_SHORTCUTS`]).
@@ -1738,7 +1753,7 @@ impl Session {
}
/// Push engine-relevant preferences into live state: every document's history limit, the
/// layer-effect cache budget and the text engine's missing-glyph protection.
/// transform and layer-effect cache budgets and the text engine's missing-glyph protection.
pub fn apply_prefs(&mut self) {
let n = self.prefs().performance.history_states.max(1) as usize;
let bytes = self.prefs().performance.history_budget_bytes();
@@ -1749,6 +1764,7 @@ impl Session {
st.trim_history();
}
photocraft_compose::set_effect_cache_budget(budget << 20);
crate::transform_cmds::set_budget(u64::from(self.prefs().performance.memory_usage_mb) << 20);
// Without waiting for the system font scan: this runs on the UI thread at launch.
photocraft_text::set_shared_missing_glyph_protection(self.prefs().type_.missing_glyph_protection());
crate::plugin_cmds::sync_prefs(self);
+5
View File
@@ -343,6 +343,11 @@ fn shortcuts_normalise_and_detect_conflicts() {
assert_eq!(normalize_shortcut("Cmd++").as_deref(), Some("Cmd++"));
assert_eq!(normalize_shortcut("Cmd+Shift"), None);
assert_eq!(normalize_shortcut("Cmd+A+B"), None);
// #6827: multi-word key names keep egui's spelling (Title case made `Pageup`, matching no key).
assert_eq!(normalize_shortcut("shift+pageup").as_deref(), Some("Shift+PageUp"));
assert_eq!(normalize_shortcut("Ctrl+Alt+PAGEDOWN").as_deref(), Some("Ctrl+Alt+PageDown"));
assert_eq!(normalize_shortcut("Cmd+openbracket").as_deref(), Some("Cmd+OpenBracket"));
assert_eq!(normalize_shortcut("home").as_deref(), Some("Home"));
let c = conflicts([("a", "Cmd+J"), ("b", "cmd+j"), ("c", "Cmd+K")]);
assert_eq!(c, vec![("Cmd+J".to_string(), vec!["a".to_string(), "b".to_string()])]);
}
+2 -1
View File
@@ -92,7 +92,8 @@ fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
// A background job when started with `Session::start` (#210): reading and decoding the
// file run on a worker, cancellable before each brush; the library changes on apply.
let params = p.clone();
let params_apply = p.clone();
// Only the keys the apply step reads: `data` can be a ~1.3 GiB base64 string.
let params_apply = json!({ "replace": p.get("replace"), "select": p.get("select") });
crate::jobs::run(
s,
label,
+9 -1
View File
@@ -775,14 +775,22 @@ fn a_damaged_preset_set_keeps_the_other_sets() {
let mut s = Session::new();
let settings = json!({"width": 1600, "height": 900, "resolution": 144});
s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap();
let export = json!({
"format": "jpg", "quality": 70, "lossless": false, "transparency": false, "scale": 50, "width": 640, "height": 480,
"lockAspect": true, "sizing": "pixels", "metadata": "exif", "profile": "srgb", "embedProfile": false, "tgaBits": "24"
});
s.execute("file.export.presets.save", json!({"name": "Web JPEG", "settings": export})).unwrap();
let saved = s.presets.to_json(&s);
for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] {
for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches", "export_presets"] {
let mut blob = saved.clone();
blob[key] = json!("not a preset set");
let mut next = Session::new();
next.load_presets_json(blob);
assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`");
assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`");
if key != "export_presets" {
assert_eq!(next.presets.export_presets, s.presets.export_presets, "damaged `{key}`");
}
}
// Null sets keep their defaults, as before.
let mut next = Session::new();
@@ -648,7 +648,10 @@ fn run_scaled(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, scale:
crop(crate::channel_cmds::target_surface(doc, plan.target.id, p)?.0, reach_rect)
};
let small = resize_surface_in_canvas(&src, f, f, Resample::Bilinear, plan.canvas);
let small_sel = resize_surface_in_canvas(&crop(sel, reach_rect), f, f, Resample::Bilinear, plan.canvas);
// The selection is scaled in 32-bit float: in 8 bits a faint, sparse one (scattered 1/255
// pixels) averages to 0 and the scaled solve would find nothing selected.
let sel_f32 = photocraft_color::PixelFormat { sample: photocraft_color::SampleType::F32, ..sel.format() };
let small_sel = resize_surface_in_canvas(&crop(sel, reach_rect).convert(sel_f32), f, f, Resample::Bilinear, plan.canvas);
ctx.check()?;
let canvas = scaled_rect(plan.canvas, f, f);
let size = photocraft_geom::Size { width: canvas.width(), height: canvas.height() };
@@ -758,4 +761,30 @@ mod tests {
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 257, "height": 256})).unwrap();
assert!(preview(&s, &json!({"offset": [20, 20]})).unwrap().is_coarse());
}
#[test]
fn a_coarse_preview_keeps_a_faint_sparse_selection() {
// An 8-bit selection of scattered 1/255 pixels: scaled down in 8 bits it averaged to 0
// everywhere, so the coarse preview failed with "the selection is empty" while the
// command itself moves it.
let mut s = textured(1200, 800);
s.edit("faint selection", |doc, _| {
let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8);
let r = Rect::new(20, 20, 1120, 720);
let data: Vec<f32> = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| if x % 4 == 0 && y % 4 == 0 { 1.0 / 255.0 } else { 0.0 })).collect();
sel.write_region(r, &data);
doc.selection = Some(sel);
Ok(())
})
.unwrap();
let p = json!({"offset": [40, 40]});
let job = preview(&s, &p).unwrap();
assert!(job.is_coarse());
let (got, damage) = job.run(&JobCtx::new()).unwrap();
assert!(!damage.is_empty());
let b = got.bounds();
let before = s.active().unwrap().doc.layers[0].surface().unwrap().read_region(b);
assert!(got.layers[0].surface().unwrap().read_region(b) != before, "the preview moves something");
s.execute("paint.contentAwareMove", p).unwrap();
}
}
+3 -1
View File
@@ -159,6 +159,8 @@ fn affine_params(cmd: &str, p: &Value, r: [f64; 4]) -> Result<Homography> {
Ok(t(px + dx, py + dy).mul(&rot).mul(&skew).mul(&scale).mul(&t(-px, -py)))
}
/// Select › Transform Selection: warps the selection outline by the params' matrix, quad or
/// scale/rotate/move, refused when the result would not fit the transform budget.
fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "select.transformSelection";
let d = s.active().ok_or(EngineError::NoDocument)?;
@@ -206,7 +208,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
let selected = s.edit("Transform Selection", |doc, _| {
if let Some(sel) = &doc.selection {
crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection")?;
crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection", sel.format())?;
doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty());
}
Ok(doc.selection.is_some())
+42 -11
View File
@@ -257,6 +257,30 @@ pub fn resolved_json(doc: &Document, r: &ResolvedSlice) -> Value {
})
}
/// A stored slice as `slice.list` and `slice.set` report it: once, with its own (canvas-clipped)
/// rectangle, the one `slice.set` takes back, and the visible `pieces` a later slice cuts it into,
/// which are numbered and exported separately (#7770). `number` is its first piece's, `null` when
/// later slices cover it wholly. `None` for an unknown id.
fn stored_json(doc: &Document, id: u32, resolved: &[ResolvedSlice]) -> Option<Value> {
let sl = doc.slices.get(id)?;
let pieces: Vec<&ResolvedSlice> = resolved.iter().filter(|r| r.id == Some(id)).collect();
let first = pieces.first();
let whole = ResolvedSlice {
number: first.map_or(0, |r| r.number),
id: Some(id),
origin: sl.origin,
rect: sl.rect.intersect(&doc.bounds()),
name: first.map_or_else(|| sl.name.clone(), |r| r.name.clone()),
kind: sl.kind,
};
let mut v = resolved_json(doc, &whole);
if first.is_none() {
v["number"] = Value::Null;
}
v["pieces"] = pieces.iter().map(|r| json!({"number": r.number, "name": r.name, "rect": [r.rect.x0, r.rect.y0, r.rect.width(), r.rect.height()]})).collect();
Some(v)
}
// ---------- commands ----------
fn new_slice(s: &mut Session, p: &Value) -> Result<Value> {
@@ -331,15 +355,7 @@ fn set_slice(s: &mut Session, p: &Value) -> Result<Value> {
Ok(id)
})?;
let d = s.active().ok_or(EngineError::NoDocument)?;
if let Some(r) = slices::resolve(&d.doc).into_iter().find(|r| r.id == Some(id)) {
return Ok(resolved_json(&d.doc, &r));
}
// Wholly under later slices: it has no visible piece, so no number.
let sl = d.doc.slices.get(id).ok_or_else(|| bad(cmd, "slice vanished"))?;
let r = ResolvedSlice { number: 0, id: Some(id), origin: sl.origin, rect: sl.rect.intersect(&d.doc.bounds()), name: sl.name.clone(), kind: sl.kind };
let mut v = resolved_json(&d.doc, &r);
v["number"] = Value::Null;
Ok(v)
stored_json(&d.doc, id, &slices::resolve(&d.doc)).ok_or_else(|| bad(cmd, "slice vanished"))
}
fn promote(s: &mut Session, p: &Value) -> Result<Value> {
@@ -453,7 +469,22 @@ fn divide(s: &mut Session, p: &Value) -> Result<Value> {
fn list(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let all: Vec<Value> = slices::resolve(&d.doc).iter().map(|r| resolved_json(&d.doc, r)).collect();
// One entry per slice, in reading order of its first piece: auto slices as they resolve, a
// stored slice once with its pieces; slices wholly covered by later ones follow.
let resolved = slices::resolve(&d.doc);
let mut listed = std::collections::HashSet::new();
let mut all: Vec<Value> = Vec::new();
for r in &resolved {
match r.id {
None => all.push(resolved_json(&d.doc, r)),
Some(id) if listed.insert(id) => all.extend(stored_json(&d.doc, id, &resolved)),
Some(_) => {}
}
}
let canvas = d.doc.bounds();
for sl in d.doc.slices.list.iter().filter(|sl| !listed.contains(&sl.id) && !sl.rect.intersect(&canvas).is_empty()) {
all.extend(stored_json(&d.doc, sl.id, &resolved));
}
Ok(json!({"slices": all, "locked": s.file_menu.slices_locked}))
}
@@ -543,7 +574,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "List Slices",
menu: &[],
shortcut: None,
params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…}], locked}",
params: "{} → {slices:[{number,id,origin:auto|layer|user,name,rect:[x,y,w,h],kind,layer,url,alt,…,pieces?:[{number,name,rect}] (a stored slice's visible parts; number null when wholly covered)}], locked}",
enabled: has_doc,
journal: false,
run: |s, _| list(s),
+24 -6
View File
@@ -57,15 +57,31 @@ fn covered_slices_keep_their_options() {
let mut s = session(8);
let under = s.execute("slice.new", json!({"rect": [0, 0, 60, 60], "name": "under"})).unwrap()["slice"].as_u64().unwrap();
s.execute("slice.new", json!({"rect": [40, 40, 60, 50], "name": "over"})).unwrap();
let l = s.execute("slice.list", json!({})).unwrap();
let pieces: Vec<&Value> = l["slices"].as_array().unwrap().iter().filter(|v| v["id"] == json!(under)).collect();
assert_eq!(pieces.len(), 2);
assert!(pieces.iter().all(|v| v["name"] == "under"), "Slice Options shows the slice's own name: {pieces:?}");
// Moved wholly under the later slice, it is still on the canvas.
let listed = |s: &mut Session| -> Vec<Value> {
let l = s.execute("slice.list", json!({})).unwrap();
l["slices"].as_array().unwrap().iter().filter(|v| v["id"] == json!(under)).cloned().collect()
};
// Listed once, with its own rectangle and name; the visible pieces are numbered separately.
let entries = listed(&mut s);
assert_eq!(entries.len(), 1, "{entries:?}");
let e = &entries[0];
assert_eq!((&e["name"], &e["rect"], &e["number"]), (&json!("under"), &json!([0, 0, 60, 60]), &json!(1)));
let pieces = e["pieces"].as_array().unwrap();
assert_eq!(pieces.len(), 2, "{pieces:?}");
assert_eq!(pieces[0], json!({"number": 1, "name": "under", "rect": [0, 0, 60, 40]}));
assert_eq!(pieces[1]["rect"], json!([0, 40, 40, 20]));
// Its listed rectangle written back through Slice Options keeps the slice whole.
s.execute("slice.set", json!({"slice": under, "rect": e["rect"]})).unwrap();
assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(0, 0, 60, 60));
// Moved wholly under the later slice, it is still on the canvas, and still listed.
let r = s.execute("slice.set", json!({"slice": under, "rect": [50, 50, 10, 10], "alt": "hidden"})).unwrap();
assert_eq!(r["number"], Value::Null);
assert_eq!(r["alt"], "hidden");
assert_eq!(r["pieces"], json!([]));
assert_eq!(doc(&s).slices.get(under as u32).unwrap().rect, Rect::new(50, 50, 60, 60));
let entries = listed(&mut s);
assert_eq!(entries.len(), 1, "a wholly covered slice is still listed: {entries:?}");
assert_eq!((&entries[0]["number"], &entries[0]["rect"]), (&Value::Null, &json!([50, 50, 10, 10])));
}
#[test]
@@ -293,7 +309,9 @@ fn rejected_slice_options_change_nothing() {
// A user slice moved off the canvas is rejected without committing the move.
let id = s.execute("slice.new", json!({"rect": [0, 0, 10, 10]})).unwrap()["slice"].as_u64().unwrap() as u32;
let past = s.active().unwrap().history.past_len();
assert!(s.execute("slice.set", json!({"slice": id, "rect": [500, 500, 10, 10]})).is_err());
let before = doc(&s).slices.clone();
assert!(s.execute("slice.set", json!({"slice": id, "rect": [500, 500, 10, 10], "alt": "gone"})).is_err());
assert_eq!(doc(&s).slices, before, "neither the move nor the option is committed");
assert_eq!(doc(&s).slices.get(id).unwrap().rect, Rect::new(0, 0, 10, 10));
assert_eq!(s.active().unwrap().history.past_len(), past);
}
+97 -4
View File
@@ -283,8 +283,11 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc
// ---------- rendering ----------
/// `top` blended over `base` with `mode` at `opacity` (a smart filter's blending options).
/// Mix a filter's result with its input, without stacking the same pixel coverage twice.
fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) -> Surface {
if opacity <= 0.0 {
return base.clone();
}
let fmt = base.format();
let area = base.content_bounds().union(&top.content_bounds());
let mut out = base.clone();
@@ -293,9 +296,38 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
}
let n = fmt.channels();
let (b, t) = (base.read_region(area), top.read_region(area));
// Alpha-preserving filters edit the same coverage. A non-Normal filter that
// changes coverage (e.g. blur) must also retain input-only pixels and admit
// filtered-only pixels, as Photoshop's Smart Filter blend oracles require.
// Classify the whole result so unchanged pixels do not switch blend rules
// independently at the edge of a blur.
let composite_coverage = mode != BlendMode::Normal && fmt.alpha && b.chunks_exact(n).zip(t.chunks_exact(n)).any(|(bp, tp)| bp.last() != tp.last());
let mut o = vec![0.0f32; b.len()];
for ((bp, tp), op) in b.chunks_exact(n).zip(t.chunks_exact(n)).zip(o.chunks_exact_mut(n)) {
let rgba = photocraft_color::blend::composite(mode, photocraft_raster::to_rgba(&fmt, bp), photocraft_raster::to_rgba(&fmt, tp), opacity);
let b = photocraft_raster::to_rgba(&fmt, bp);
let t = photocraft_raster::to_rgba(&fmt, tp);
if composite_coverage {
let rgba = photocraft_color::blend::composite(mode, b, t, opacity);
photocraft_raster::from_rgba_into(&fmt, rgba, op);
continue;
}
let cb = [b[0], b[1], b[2]];
let ct = [t[0], t[1], t[2]];
let color = if fmt.sample == photocraft_color::SampleType::F32 {
photocraft_color::blend::blend_rgb_hdr(mode, cb, ct)
} else {
photocraft_color::blend::blend_rgb(mode, cb, ct)
};
// A filter edits this surface, rather than adding a second source-over layer. Mix
// premultiplied results so alpha-preserving filters keep coverage at every opacity,
// while Normal-mode filters that change coverage interpolate it too.
let a = b[3] + (t[3] - b[3]) * opacity;
let mut rgba = [0.0, 0.0, 0.0, a];
if a > 0.0 {
for c in 0..3 {
rgba[c] = (cb[c] * b[3] * (1.0 - opacity) + color[c] * t[3] * opacity) / a;
}
}
photocraft_raster::from_rgba_into(&fmt, rgba, op);
}
out.write_region(area, &o);
@@ -314,7 +346,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
let extent = canvas.union(&placed.content_bounds());
for f in sm.smart_filters.iter().filter(|f| f.visible) {
cur = apply_smart_filter(f, &cur, canvas, extent)?;
cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent)?;
}
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
@@ -322,6 +354,21 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
})
}
/// The reference bounds of smart filter `f`. Render filters work within the smart object, as
/// Photoshop's do: Lens Flare's centre is a fraction of the object, and Clouds and Fibers fill it
/// and size their pattern by it. The object is its placed content's bounds (which leaves out
/// fully transparent edges of the source); the other filters use the canvas, as on a layer.
fn filter_bounds(f: &SmartFilter, placed: &Surface, canvas: Rect) -> Rect {
match placed.content_bounds() {
b if renders_in_object(f) && !b.is_empty() => b,
_ => canvas,
}
}
fn renders_in_object(f: &SmartFilter) -> bool {
matches!(f.command.as_str(), "filter.render.clouds" | "filter.render.differenceClouds" | "filter.render.fibers" | "filter.render.lensFlare")
}
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
/// requesting session's authority denies the filter's params: callers surface the error instead of
/// rendering (and caching) unfiltered pixels.
@@ -424,10 +471,18 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
None => (0, placed()?),
};
let n = filters.len() as f32;
// The object's bounds, found once: the placed stage may have been evicted from the cache, and
// placing it again for every render filter would decode the source each time.
let mut object_bounds: Option<Rect> = None;
for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) {
ctx.check()?;
ctx.progress(i as f32 / n, "");
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent };
let bounds = match object_bounds {
_ if !renders_in_object(f) => canvas,
Some(b) => b,
None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)),
};
cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent)?, extent: cur.extent };
stack_cache::put(*key, &cur);
}
ctx.progress(1.0, "");
@@ -1018,10 +1073,40 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// The Displace filter, whose map a smart filter keeps embedded in its params.
const DISPLACE: &str = "filter.distort.displace";
fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.smartFilter.setParams";
let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?;
let authorize = s.authorize;
// A Displace given another open document as its map embeds that one in place of the old
// (#7423). It is read here, before the edit, from the session; the request is authorized
// first, as the merged params are below. A new `mapPath` stays a path (read next to the
// document on each re-render) and only drops the old embedded map; an empty one, or a
// negative `mapDocument` (the dialog's "None"), names no map.
let new_map_asked = crate::filters_ext::map_document(&new).is_some();
let new_path = crate::filters_ext::map_path(&new).is_some();
let displace = {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let sm = smart(doc, layer_param(s, p)?)?;
sm.smart_filters.get(filter_index(CMD, p, sm)?).is_some_and(|f| f.command == DISPLACE)
};
let new_map = if displace && new_map_asked {
// The old embedded map, if the edit passed it back, gives way to the one named.
let request = crate::filters_ext::displace_request(&new);
if let Some(gate) = authorize {
gate(DISPLACE, &request)?;
}
let mut fp = crate::filters::params_for(DISPLACE, &request).ok_or_else(|| bad(CMD, "unreadable Displace settings"))?;
crate::filters_ext::resolve(s, &mut fp, &request)?;
match fp {
photocraft_algo::FilterParams::Displace { map, .. } => map,
_ => None,
}
} else {
None
};
edit_filters(s, p, "Edit Smart Filter", |sm| {
let i = filter_index(CMD, p, sm)?;
let f = &mut sm.smart_filters[i];
@@ -1032,6 +1117,14 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
(Value::Object(old), Value::Object(n)) => old.extend(n),
(slot, n) => *slot = n,
}
if f.command == DISPLACE {
if let Some(map) = &new_map {
f.params = photocraft_io::smart_map::displace_embed_map(&f.params, map)
.ok_or_else(|| bad(CMD, "the displacement map is too large to keep in a Smart Object's filter"))?;
} else if new_path {
f.params = photocraft_io::smart_map::displace_without_map(&f.params);
}
}
// Authorize the underlying command with the complete merged params, including paths
// already stored in the document, before committing the edit or starting a worker.
if let Some(gate) = authorize {
+151
View File
@@ -7,6 +7,77 @@ const DEPTHS: [u64; 3] = [8, 16, 32];
const W: i32 = 64;
const H: i32 = 48;
/// A partial filter strength keeps the source alpha and interpolates the filtered coverage, at every sample depth.
#[test]
fn filter_strength_preserves_alpha_and_interpolates_changed_coverage() {
for sample in photocraft_color::SampleType::ALL {
let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true };
let mut base = Surface::new(fmt);
let mut filtered = Surface::new(fmt);
base.fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.4, 0.6, 0.5]);
filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.5]);
let before = base.pixel(0, 0);
let after = filtered.pixel(0, 0);
let tolerance = match sample {
photocraft_color::SampleType::U8 => 1.0 / 255.0,
photocraft_color::SampleType::U16 => 1.0 / 65535.0,
photocraft_color::SampleType::F32 => 1e-6,
};
for opacity in [0.0, 0.01, 0.5, 1.0] {
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Luminosity] {
let mixed = blend_surfaces(&base, &filtered, mode, opacity).pixel(0, 0);
assert!((mixed[3] - before[3]).abs() <= tolerance);
if mode == BlendMode::Normal {
for c in 0..3 {
assert!((mixed[c] - (before[c] + (after[c] - before[c]) * opacity)).abs() <= tolerance);
}
}
}
}
filtered.fill_rect(Rect::new(0, 0, 1, 1), &[0.8, 0.6, 0.4, 0.0]);
let mixed = blend_surfaces(&base, &filtered, BlendMode::Normal, 0.5).pixel(0, 0);
assert!((mixed[3] - before[3] * 0.5).abs() <= tolerance);
for c in 0..3 {
assert!((mixed[c] - before[c]).abs() <= tolerance);
}
}
}
/// Blending a filter result in Screen, Multiply or Luminosity keeps source-over coverage where the filter changed alpha.
#[test]
fn non_normal_coverage_changes_retain_source_over_semantics() {
for sample in photocraft_color::SampleType::ALL {
let fmt = PixelFormat { mode: photocraft_color::ColorMode::Rgb, sample, alpha: true };
let mut base = Surface::new(fmt);
let mut filtered = Surface::new(fmt);
// Input-only, output-only, opaque input with blurred output, and a
// translucent pixel whose alpha happens to be unchanged by this filter.
for (x, (ba, ta)) in [(0.5, 0.0), (0.0, 0.5), (1.0, 0.25), (0.5, 0.5)].into_iter().enumerate() {
let x = x as i32;
base.fill_rect(Rect::new(x, 0, 1, 1), &[0.2, 0.4, 0.6, ba]);
filtered.fill_rect(Rect::new(x, 0, 1, 1), &[0.8, 0.6, 0.4, ta]);
}
for mode in [BlendMode::Screen, BlendMode::Multiply, BlendMode::Luminosity] {
for opacity in [0.0, 0.01, 0.5, 1.0] {
let mixed = blend_surfaces(&base, &filtered, mode, opacity);
for x in 0..4 {
if opacity == 0.0 {
assert_eq!(mixed.pixel(x, 0), base.pixel(x, 0));
continue;
}
let b = photocraft_raster::to_rgba(&fmt, &base.pixel(x, 0));
let t = photocraft_raster::to_rgba(&fmt, &filtered.pixel(x, 0));
let expected = photocraft_color::blend::composite(mode, b, t, opacity);
let mut reference = Surface::new(fmt);
reference.fill_rect(Rect::new(x, 0, 1, 1), &expected);
assert_eq!(mixed.pixel(x, 0), reference.pixel(x, 0), "{sample:?} {mode:?} {opacity} x={x}");
}
}
}
}
}
/// A `depth`-bit document with one empty layer.
fn session(depth: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap();
@@ -1139,3 +1210,83 @@ fn displace_smart_filter_reads_its_map_path_on_re_render() {
}
std::fs::remove_dir_all(&dir).ok();
}
/// Clouds and Difference Clouds on a smart object are smart filters, as in Photoshop, and keep
/// the colours they were drawn with: re-rendering after the colours change gives the same clouds.
#[test]
fn clouds_on_a_smart_object_are_smart_filters_with_their_colours() {
for depth in DEPTHS {
let mut s = session(depth);
paint(&mut s);
s.execute("filter.convertForSmartFilters", json!({})).unwrap();
s.tools.foreground = [1.0, 0.0, 0.0, 1.0];
s.tools.background = [0.0, 0.0, 1.0, 1.0];
s.execute("filter.render.clouds", json!({"seed": 5})).unwrap();
s.execute("filter.render.differenceClouds", json!({"seed": 9})).unwrap();
let sm = active_smart(&s);
let commands: Vec<&str> = sm.smart_filters.iter().map(|f| f.command.as_str()).collect();
assert_eq!(commands, ["filter.render.clouds", "filter.render.differenceClouds"]);
assert_eq!(sm.smart_filters[0].params["foreground"], json!([1.0, 0.0, 0.0, 1.0]));
assert_eq!(sm.smart_filters[1].params["background"], json!([0.0, 0.0, 1.0, 1.0]));
// Red-to-blue clouds, and their difference, have no green: they fill the object (painted
// from (-6, 5) to (40, 38)) over the white background.
let rendered = flat(&s);
let object: Vec<[f32; 4]> = (5..38).flat_map(|y| (0..40).map(move |x| (y * W + x) as usize)).map(|i| rendered[i]).collect();
assert!(object.iter().all(|p| p[1] < 0.02), "depth {depth}");
assert!(max_diff(&object, &vec![object[0]; object.len()]) > 0.05, "the clouds vary");
s.tools.foreground = [0.0, 1.0, 0.0, 1.0];
s.execute("layer.smartFilter.setParams", json!({"index": 1, "params": {"seed": 9}})).unwrap();
assert!(max_diff(&flat(&s), &rendered) < 1e-6, "depth {depth}: same params, same clouds");
s.undo();
s.undo();
assert_eq!(active_smart(&s).smart_filters.len(), 1);
}
}
/// Render filters on a smart object work within the object, as in Photoshop (and as PSD stores
/// them): Lens Flare's centre is a fraction of the object's bounds, not of the canvas.
#[test]
fn lens_flare_centres_on_the_smart_object() {
let mut s = session(8);
let block = Rect::new(32, 24, W, H);
s.edit("setup", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let px = photocraft_raster::from_rgba(&fmt, [0.1, 0.1, 0.1, 1.0]);
surf.write_region(block, &px.repeat((block.width() * block.height()) as usize));
Ok(())
})
.unwrap();
convert(&mut s);
s.execute("filter.render.lensFlare", json!({"centerX": 0.5, "centerY": 0.5, "lens": "prime105"})).unwrap();
// The centre of the brightest pixels inside the object (the canvas around it is white).
let px = flat(&s);
let inside: Vec<(i32, i32, f32)> =
(block.y0..block.y1).flat_map(|y| (block.x0..block.x1).map(move |x| (x, y))).map(|(x, y)| (x, y, px[(y * W + x) as usize][1])).collect();
let peak = inside.iter().map(|p| p.2).fold(0.0, f32::max);
let core: Vec<_> = inside.iter().filter(|p| p.2 >= peak - 0.02).collect();
let (x, y) = (core.iter().map(|p| p.0).sum::<i32>() / core.len() as i32, core.iter().map(|p| p.1).sum::<i32>() / core.len() as i32);
assert!((x - 48).abs() <= 1 && (y - 36).abs() <= 1, "flare at {x},{y}, the object's centre is 48,36");
}
/// Moving a smart object moves its Clouds and Fibers with it: the pattern is anchored at the
/// object, not at the canvas, so re-rendering after the move doesn't change the texture.
#[test]
fn clouds_and_fibers_move_with_the_smart_object() {
for (command, params) in [("filter.render.clouds", json!({"seed": 3})), ("filter.render.fibers", json!({"seed": 3}))] {
let mut s = session(8);
paint(&mut s);
let id = convert(&mut s);
s.execute(command, params).unwrap();
let before = flat(&s);
s.execute("edit.transform", json!({"layer": id, "matrix": [1, 0, 0, 1, 8, 6]})).unwrap();
let after = flat(&s);
// Inside the object (painted from (-6, 5) to (40, 38)) both before and after the move.
for y in 8..30 {
for x in 2..30 {
let (a, b) = (before[(y * W + x) as usize], after[((y + 6) * W + x + 8) as usize]);
assert!((0..4).all(|c| (a[c] - b[c]).abs() < 1e-3), "{command} at {x},{y}: {a:?} vs {b:?}");
}
}
}
}
+3 -3
View File
@@ -95,8 +95,8 @@ fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection
/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the
/// selection's, so a moved selection snapped back to where it started).
fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect)
fn target_rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(doc, l)).and_then(to_rect)
}
fn snappable(l: &photocraft_doc::Layer) -> bool {
@@ -132,7 +132,7 @@ impl SnapTargets {
if !l.visible || exclude.contains(&l.id) {
continue;
}
if let Some(r) = target_rect_of(l) {
if let Some(r) = target_rect_of(doc, l) {
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
}
}
+70 -7
View File
@@ -362,6 +362,7 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Aco,
Act,
Ase,
}
@@ -369,6 +370,7 @@ impl Format {
fn parse(s: &str) -> Option<Format> {
match s.trim_start_matches('.').to_ascii_lowercase().as_str() {
"aco" => Some(Format::Aco),
"act" => Some(Format::Act),
"ase" => Some(Format::Ase),
_ => None,
}
@@ -376,6 +378,7 @@ impl Format {
fn id(self) -> &'static str {
match self {
Format::Aco => "aco",
Format::Act => "act",
Format::Ase => "ase",
}
}
@@ -462,6 +465,21 @@ fn kind_to_ase(k: SwatchKind) -> AseKind {
}
}
/// Colors in an `.act` color table (what the format holds).
pub const ACT_COLORS: usize = photocraft_psd::act::COLOR_COUNT;
/// `.act` bytes as `#rrggbb` colors plus the transparent index, for the Color Table dialog.
pub fn act_table(bytes: &[u8]) -> std::result::Result<(Vec<String>, Option<u64>), String> {
let f = photocraft_psd::act::parse(bytes).map_err(|e| e.to_string())?;
let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]);
Ok((f.used().iter().map(hex).collect(), f.transparent_index().map(|i| i as u64)))
}
/// Encode colors as `.act` bytes (at most [`ACT_COLORS`]); the transparent index is kept.
pub fn act_bytes(colors: &[[u8; 3]], transparent: Option<u16>) -> std::result::Result<Vec<u8>, String> {
photocraft_psd::act::write(colors, Some(colors.len().min(ACT_COLORS) as u16), transparent).map_err(|e| e.to_string())
}
/// Parse a swatch file into groups (`group` names the group for ungrouped colours).
pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Result<(Vec<Group<Swatch>>, Vec<String>), String> {
let mut warnings = Vec::new();
@@ -484,6 +502,19 @@ pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Resu
.collect();
out.push(Group::new(group, items));
}
Format::Act => {
let f = photocraft_psd::act::parse(bytes).map_err(|e| format!("not a readable .act color table: {e}"))?;
if let Some(t) = f.transparent_index() {
warnings.push(format!("color {} is the table's transparent color; swatches have no transparency so it was kept opaque", t + 1));
}
let items = f
.used()
.iter()
.enumerate()
.map(|(i, c)| Swatch { name: format!("Swatch {}", i + 1), color: SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0)), kind: SwatchKind::Process })
.collect();
out.push(Group::new(group, items));
}
Format::Ase => {
let f = photocraft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?;
let mut skipped = 0;
@@ -541,6 +572,32 @@ pub fn write_file(groups: &[Group<Swatch>], format: Format) -> std::result::Resu
let bytes = photocraft_psd::aco::write(&swatches).map_err(|e| e.to_string())?;
Ok((bytes, n, warnings))
}
Format::Act => {
// `.act` holds RGB only: HSB rides along as RGB, anything else is left out.
let rgb8 = |v: [f32; 3]| v.map(|c| (unit(c) * 255.0).round() as u8);
let mut triplets = Vec::new();
let mut skipped = 0;
for s in groups.iter().flat_map(|g| g.items.iter()) {
match s.color {
SwatchColor::Rgb(v) => triplets.push(rgb8(v)),
SwatchColor::Hsb(v) => triplets.push(rgb8(hsb_to_rgb(v))),
_ => skipped += 1,
}
}
if groups.len() > 1 {
warnings.push(".act files have no groups; the swatches were saved as one list".into());
}
if skipped > 0 {
warnings.push(format!("{skipped} non-RGB swatches can't be stored in an .act file and were left out (save as .aco to keep them)"));
}
if triplets.len() > photocraft_psd::act::COLOR_COUNT {
warnings.push(format!("only the first {} swatches fit in an .act file", photocraft_psd::act::COLOR_COUNT));
triplets.truncate(photocraft_psd::act::COLOR_COUNT);
}
let n = triplets.len();
let bytes = photocraft_psd::act::write(&triplets, Some(n as u16), None).map_err(|e| e.to_string())?;
Ok((bytes, n, warnings))
}
Format::Ase => {
let mut books = 0;
let mut entries = Vec::with_capacity(groups.len());
@@ -571,12 +628,18 @@ pub fn write_file(groups: &[Group<Swatch>], format: Format) -> std::result::Resu
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "swatches.import";
let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco or .ase file")?;
let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco, .act or .ase file")?;
// `.act` has no magic: exactly 768/772 bytes. An `.act` whose first colour is `0, 1, x` or
// `0, 2, x` starts like an `.aco`, and the .aco reader ignores trailing bytes, so at those
// lengths an .aco header only counts when its sections fill the file exactly.
let act_len = bytes.len() == photocraft_psd::act::PALETTE_LEN || bytes.len() == photocraft_psd::act::PALETTE_LEN + photocraft_psd::act::TRAILER_LEN;
let format = match str_param(p, "format") {
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?,
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?,
None if ext_format(str_param(p, "path")) == Some(Format::Act) => Format::Act,
None if bytes.starts_with(photocraft_psd::ase::SIGNATURE) => Format::Ase,
None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) => Format::Aco,
None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco or .ase swatch file"))?,
None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) && (!act_len || photocraft_psd::aco::sections_len(&bytes) == Some(bytes.len())) => Format::Aco,
None if act_len => Format::Act,
None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco, .act or .ase swatch file"))?,
};
let mode = str_param(p, "mode").unwrap_or("append");
let replace = match mode {
@@ -634,7 +697,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "swatches.export";
let path = str_param(p, "path");
let format = match str_param(p, "format") {
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?,
Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|act|ase)")))?,
None => ext_format(path).unwrap_or(Format::Aco),
};
let mut groups: Vec<Group<Swatch>> = match name_param(p, "group", CMD)? {
@@ -790,7 +853,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Import Swatches…",
menu: &[],
shortcut: None,
params: r##"{"path":".aco or .ase file"?,"data":base64 bytes?,"format":"aco|ase"? (detected),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##,
params: r##"{"path":".aco, .act or .ase file"?,"data":base64 bytes?,"format":"aco|act|ase"? (detected; an .act by its .act path or its 768/772-byte length),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##,
enabled: always,
run: import,
journal: true,
@@ -800,7 +863,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Export Swatches…",
menu: &[],
shortcut: None,
params: r##"{"format":"aco|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##,
params: r##"{"format":"aco|act|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##,
enabled: has_swatches,
run: export,
journal: true,
+58
View File
@@ -146,6 +146,64 @@ fn aco_round_trip_keeps_16_bit_values_and_books() {
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn act_round_trip_loads_a_photoshop_color_table() {
// A Photoshop-saved table: 256 RGB colors plus the count/transparent trailer.
let mut colors = [[0u8; 3]; 256];
colors[0] = [255, 255, 255];
for (i, c) in colors.iter_mut().enumerate().skip(1) {
let v = i as u8;
*c = [v, 255 - v, 128];
}
let file = photocraft_psd::act::write(&colors, Some(256), Some(255)).unwrap();
assert_eq!(file.len(), 772);
let mut s = Session::new();
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Table", "mode": "replace"})).unwrap();
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256)));
assert_eq!(r["warnings"].as_array().unwrap().len(), 1, "the transparent color is reported");
assert_eq!(s.presets.swatches[0].items[0].color, SwatchColor::Rgb([1.0, 1.0, 1.0]));
assert_eq!(s.presets.swatches[0].items[1].name, "Swatch 2");
// Sniffed by length without a format, and by extension from a path.
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap();
assert_eq!(r["format"].as_str(), Some("act"));
// Export keeps the 256 RGB colors; the trailer comes back in canonical form
// (swatches have no transparency to preserve).
let canon = photocraft_psd::act::write(&colors, Some(256), None).unwrap();
assert_eq!(export_bytes(&mut s, json!({"format": "act"})), canon);
// A bigger library is capped at 256 with a warning; groups collapse to one list.
for i in 0..300 {
s.execute("swatches.add", json!({"name": format!("Extra {i}"), "color": {"model": "rgb", "values": [1, 0, 0]}})).unwrap();
}
let r = s.execute("swatches.export", json!({"format": "act"})).unwrap();
assert_eq!(r["count"], 256);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("first 256"))));
}
#[test]
fn act_that_starts_like_an_aco_is_still_an_act() {
// First colour 0, 1, 0 and second colour 5, …: the first four bytes read as an .aco version 1
// header with five swatches, and the .aco reader would take 50 bytes of junk from it.
let mut colors = [[0u8; 3]; 256];
colors[0] = [0, 1, 0];
for (i, c) in colors.iter_mut().enumerate().skip(1) {
*c = [(i as u8).wrapping_add(4), 7, 9];
}
for file in [photocraft_psd::act::write(&colors, None, None).unwrap(), photocraft_psd::act::write(&colors, Some(256), None).unwrap()] {
assert_eq!(&file[..4], &[0, 1, 0, 5]);
let mut s = Session::new();
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap();
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256)), "{} bytes", file.len());
}
// A real .aco of exactly 768 bytes (ten swatches with 25-character names) stays an .aco.
let swatches: Vec<AcoSwatch> = (0..10u16).map(|i| AcoSwatch { name: format!("{i:0>25}"), color: AcoColor::Rgb([i * 1000, 0, 65535]) }).collect();
let file = photocraft_psd::aco::write(&swatches).unwrap();
assert_eq!(file.len(), photocraft_psd::act::PALETTE_LEN);
let mut s = Session::new();
let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "mode": "replace"})).unwrap();
assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("aco"), Some(10)));
assert_eq!(s.presets.swatches[0].items[9].name, format!("{:0>25}", 9));
}
#[test]
fn ase_round_trip_keeps_groups_floats_and_kinds() {
let src = AseFile {
+22 -2
View File
@@ -74,9 +74,20 @@ impl PaintingSymmetry {
Self::Preset(p) => {
// The first axis' direction through the map's linear part gives the new angle; a
// flip mirrors it.
let [dx, dy] = *p.directions().first()?;
let directions = p.directions();
let [dx, dy] = *directions.first()?;
let [m0, m1, m2, m3, _, _] = a.m;
let turn = (m1 * dx + m3 * dy).atan2(m0 * dx + m2 * dy) - dy.atan2(dx);
let mapped = |[dx, dy]: [f64; 2]| (m1 * dx + m3 * dy).atan2(m0 * dx + m2 * dy);
let mut axis = mapped([dx, dy]);
// A non-uniform Image Size leaves a turned dual preset's two axes non-perpendicular,
// which it can't represent: the new axes split the difference, each turned off its
// mapped axis by the same angle. Maps that keep right angles give no difference.
if let Some(&second) = directions.get(1) {
let off = (mapped(second) - axis - std::f64::consts::FRAC_PI_2).rem_euclid(std::f64::consts::PI);
let off = if off > std::f64::consts::FRAC_PI_2 { off - std::f64::consts::PI } else { off };
axis += off / 2.0;
}
let turn = axis - dy.atan2(dx);
let rotation = (p.rotation + turn.to_degrees()).rem_euclid(360.0);
if !rotation.is_finite() {
return None;
@@ -288,6 +299,15 @@ mod tests {
let p = preset(&s);
assert_eq!(p.center, [90.0, 40.0], "edge coordinates mirror about the canvas width, not the last pixel");
assert!((p.rotation - 90.0).abs() < 1e-9, "the 45° axis mirrors to 135°: {}", p.rotation);
// A turned dual preset under a non-uniform Image Size: its axes (135° and 45°) map to
// 153.4° and 26.6°, no longer perpendicular; the preset splits the difference.
s.execute("paint.setSymmetry", json!({"mode": "dual", "center": [30, 40], "rotation": 45})).unwrap();
s.execute("image.imageSize", json!({"width": 240, "height": 200})).unwrap();
let p = preset(&s);
assert_eq!(p.center, [60.0, 40.0]);
assert!((p.rotation - 45.0).abs() < 1e-9, "both axes 18.4° off, not one 0° and one 36.9°: {}", p.rotation);
s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap();
assert!((preset(&s).rotation - 315.0).abs() < 1e-9, "a flip keeps right angles (axes at 45° and 135° again): {}", preset(&s).rotation);
// A path axis keeps lying on its (moved) source path.
let mut s = Session::new();
+103
View File
@@ -158,6 +158,33 @@ fn file_new_keeps_defaults_and_supported_modes_and_depths() {
}
}
#[test]
fn file_new_tags_the_document_with_the_chosen_profile() {
// #321: New Document › Color Profile, as in Photoshop: another profile than the working space.
let desc = |s: &Session| {
let bytes = s.active().unwrap().doc.icc_profile.clone();
bytes.map(|b| photocraft_cms::Profile::parse(&b).unwrap().description)
};
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4, "profile": "adobe-rgb-compat"})).unwrap();
assert_eq!(desc(&s).as_deref(), Some(photocraft_cms::Builtin::AdobeRgbCompat.description()));
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk", "profile": "coated-cmyk"})).unwrap();
assert!(desc(&s).is_some());
// The working space (the default, or asked for) leaves it as before.
for p in [json!({"width": 4, "height": 4}), json!({"width": 4, "height": 4, "profile": "working"})] {
s.execute("file.new", p).unwrap();
assert_eq!(desc(&s), None);
}
// A profile that doesn't suit the mode, an unknown one or a wrong type opens nothing.
let open = s.documents().len();
let (active, steps) = (s.active().unwrap().doc.id, s.active().unwrap().history.past_len());
for bad in [json!("coated-cmyk"), json!("no-such-profile"), json!(""), json!(7), json!(null)] {
assert!(s.execute("file.new", json!({"width": 4, "height": 4, "profile": bad})).is_err(), "{bad}");
assert_eq!(s.documents().len(), open, "{bad}");
assert_eq!((s.active().unwrap().doc.id, s.active().unwrap().history.past_len()), (active, steps), "{bad}");
}
}
#[test]
fn file_new_variants() {
let mut s = Session::new();
@@ -1188,6 +1215,55 @@ fn selecting_a_layer_does_not_dirty_the_document() {
assert!(s.active().unwrap().is_dirty());
}
#[test]
fn selection_tools_do_not_dirty_the_document() {
// #6865: marquee, lasso, Select All / Inverse / Deselect and their undo and redo are
// history steps, but not edits, so a clean document stays clean.
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth})).unwrap();
let past = s.active().unwrap().history.past_len();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 20, "height": 10})).unwrap();
s.execute("select.rect", json!({"x": 30, "y": 20, "width": 8, "height": 8, "ellipse": true, "mode": "add"})).unwrap();
s.execute("select.lasso", json!({"points": [[0, 0], [40, 0], [20, 30]], "mode": "subtract"})).unwrap();
s.execute("select.inverse", json!({})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
assert!(s.active().unwrap().history.past_len() > past, "selections are still history steps");
assert!(!s.active().unwrap().is_dirty(), "depth {depth}: selecting is not an edit");
assert!(s.undo() && s.undo());
assert!(s.redo());
assert!(!s.active().unwrap().is_dirty(), "depth {depth}: undoing a selection is not an edit");
s.execute("history.selectState", json!({"state": past})).unwrap();
assert!(!s.active().unwrap().is_dirty(), "depth {depth}: stepping back over selections is not an edit");
// A real edit still dirties, with or without a selection, and a selection can't clean it.
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
assert!(s.active().unwrap().is_dirty());
s.execute("select.deselect", json!({})).unwrap();
assert!(s.active().unwrap().is_dirty());
assert!(s.undo());
assert!(s.active().unwrap().is_dirty(), "the new layer is still unsaved");
}
}
#[test]
fn selections_keep_a_document_with_a_video_layer_clean() {
// #6865: video frames compare by tile pointer, so selecting in a document with a video
// layer doesn't mark it modified either; changing the frames still does.
let mut s = session_with_doc();
s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap();
let st = s.active_mut().unwrap();
st.saved_revision = st.revision;
s.execute("select.rect", json!({"x": 1, "y": 1, "width": 4, "height": 4})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
assert!(!s.active().unwrap().is_dirty(), "selecting is not an edit");
assert!(s.undo());
assert!(!s.active().unwrap().is_dirty(), "undoing a selection is not an edit");
s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap();
assert!(s.active().unwrap().is_dirty(), "a new frame is an edit");
}
#[test]
fn coalesced_edits_share_one_history_step() {
let mut s = Session::new();
@@ -1639,3 +1715,30 @@ fn merge_down_on_a_group_merges_the_group() {
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some());
}
/// `restore_layer_selection` puts a multi-layer selection back, drops gone layers, keeps a clean
/// document clean, and refuses a gone active layer without changing anything.
#[test]
fn restore_layer_selection_puts_back_what_still_exists() {
let mut s = session_with_doc();
let id = |v: Value| LayerId(v["layer"].as_u64().unwrap());
let a = id(s.execute("layer.new.layer", json!({})).unwrap());
let b = id(s.execute("layer.new.layer", json!({})).unwrap());
let gone = LayerId(u64::MAX);
s.active_mut().unwrap().saved_revision = s.active().unwrap().revision;
s.restore_layer_selection(Some(b), &[a, gone, b], Some(a)).unwrap();
let st = s.active().unwrap();
assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (Some(b), vec![a, b], Some(a)));
assert_eq!(st.saved_revision, st.revision, "selection is not an edit");
let snap = |s: &Session| {
let st = s.active().unwrap();
(st.active_layer, st.selected_layers(), st.layer_anchor, st.revision, st.saved_revision)
};
let before = snap(&s);
assert!(matches!(s.restore_layer_selection(Some(gone), &[gone], None), Err(EngineError::NoLayer(_))));
assert_eq!(snap(&s), before, "an error changes nothing");
s.restore_layer_selection(None, &[a], Some(a)).unwrap();
let st = s.active().unwrap();
assert_eq!((st.active_layer, st.selected_layers(), st.layer_anchor), (None, vec![], None), "no layer selected");
assert!(Session::new().restore_layer_selection(None, &[], None).is_err(), "no document");
}
+288 -49
View File
@@ -1,11 +1,14 @@
//! Edit › Free Transform / Transform: projective transforms of layers (and the selection).
use photocraft_algo::transform::{Homography, Interp, warp_output_size, warp_surface, warp_surface_with};
use photocraft_algo::transform::{Homography, Interp, WARP_REACH, warp_output_clipped, warp_output_size, warp_surface, warp_surface_with};
use photocraft_color::PixelFormat;
#[cfg(test)]
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::{Interrupt, Surface};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
@@ -29,7 +32,7 @@ pub fn moves_whole(layer: &Layer) -> bool {
/// content within the selection, or all of it for a layer that [`moves_whole`] (#2630).
/// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag.
pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
let content = whole_layer_bounds(layer);
let content = whole_layer_bounds(doc, layer);
match &doc.selection {
Some(sel) if !moves_whole(layer) => content.intersect(&sel.content_bounds()),
_ => content,
@@ -54,12 +57,12 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
}
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect {
pub(crate) fn whole_layer_bounds(doc: &Document, layer: &Layer) -> Rect {
let content = match &layer.content {
LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).fold(Rect::EMPTY, |a, b| a.union(&b)),
// A soft shape (Density below 100 % or a Feather) renders past its path, over the whole
// canvas below 100 %: its frame follows the path and stroke, as in Photoshop (#5846).
LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::hard_shape_bounds(sh).unwrap_or(Rect::EMPTY),
LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::soft_shape_frame(sh, doc.pixel_format(), doc.bounds()).unwrap_or(Rect::EMPTY),
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
};
if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content }
@@ -260,30 +263,66 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
false
}
/// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses
/// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not
/// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused
/// rather than clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px
/// on a side before anything could fail.
pub const MAX_TRANSFORM_PIXELS: u64 = 1 << 30;
/// The memory (bytes) a single Free Transform warp's result may take: Preferences › Performance ›
/// Memory Usage, which [`Session::apply_prefs`] sets, rather than a fixed 2³⁰ px, so a stitched
/// layer of a few gigapixels transforms on a machine that has the memory for it (#7807). Only the
/// size of the result counts, not where it lands, so moving a layer far off the canvas stays
/// allowed as far as the warp reaches ([`WARP_REACH`]). A scale past it is refused rather than
/// clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px on a side
/// before anything could fail.
static BUDGET: AtomicU64 = AtomicU64::new((crate::prefs::DEFAULT_MEMORY_USAGE_MB as u64) << 20);
/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one.
#[cfg(not(test))]
const BUDGET: u64 = MAX_TRANSFORM_PIXELS;
#[cfg(test)]
const BUDGET: u64 = 1 << 20;
/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget,
/// before anything is built. `what` names the surface in the message.
pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> {
check_budget_with_limit(src, h, what, BUDGET)
/// Set the memory (bytes) one transform's result may take (see [`budget`]).
pub fn set_budget(bytes: u64) {
BUDGET.store(bytes, Ordering::Relaxed);
}
fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> {
/// The memory (bytes) one transform's result may take: Memory Usage.
pub fn budget() -> u64 {
BUDGET.load(Ordering::Relaxed)
}
/// What [`check_budget`] holds a result to: [`budget`].
#[cfg(not(test))]
fn active_budget() -> u64 {
budget()
}
/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one:
/// 2²⁰ px of the widest format (32-bit CMYK with alpha, 20 MB), so a result within the old 2²⁰ px
/// test limit still fits at any depth.
#[cfg(test)]
fn active_budget() -> u64 {
(1 << 20) * PixelFormat::new(ColorMode::Cmyk, SampleType::F32, true).bytes_per_pixel() as u64
}
/// Refuse a warp of the content inside `src` (pixels of `format`) by `h` whose result would take
/// more memory than the budget, or reach past where the warp places pixels (it would be clipped),
/// before anything is built. `what` names the surface in the message, which names the setting or
/// the reach rather than a shortage: nothing has been allocated yet.
pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str, format: PixelFormat) -> Result<()> {
check_budget_with_limit(src, h, what, format, active_budget())
}
/// [`check_budget`] against `budget` bytes (the tests price the production budget with it).
fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, format: PixelFormat, budget: u64) -> Result<()> {
let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) };
if w.saturating_mul(ht) > budget {
let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {budget} px limit");
return Err(crate::allocation::error("transforming pixels", &detail));
// The warp writes each tile into the result as it goes, so the result is what it holds; it
// adds alpha to a format without it.
let bpp = PixelFormat::new(format.mode, format.sample, true).bytes_per_pixel() as u64;
let bytes = w.saturating_mul(ht).saturating_mul(bpp);
if bytes > budget {
let mb = |b: u64| b.div_ceil(1 << 20);
return Err(EngineError::Other(format!(
"too large to transform: the transformed {what} would be {w} x {ht} px ({} MB), more than the {} MB Memory Usage allows (Preferences › Performance); the document was not changed",
mb(bytes),
budget >> 20
)));
}
if warp_output_clipped(src, h) {
return Err(EngineError::Other(format!(
"too large to transform: the transformed {what} would reach past ±{WARP_REACH} px, the farthest a transform places pixels; the document was not changed"
)));
}
Ok(())
}
@@ -303,10 +342,10 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
LayerContent::Text(_) | LayerContent::Shape(_) => {}
LayerContent::Smart(sm) => {
if let Some(c) = &sm.cache {
check_budget(c.content_bounds(), h, "smart object")?;
check_budget(c.content_bounds(), h, "smart object", c.format())?;
}
if let Some(m) = &sm.filter_mask {
check_budget(m.surface.content_bounds(), h, "smart filter mask")?;
check_budget(m.surface.content_bounds(), h, "smart filter mask", m.surface.format())?;
}
}
_ => {
@@ -316,7 +355,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
Some(sel) => surf.content_bounds().intersect(&sel.content_bounds()),
None => surf.content_bounds(),
};
check_budget(src, h, &format!("layer \"{}\"", l.name))?;
check_budget(src, h, &format!("layer \"{}\"", l.name), surf.format())?;
}
}
}
@@ -328,7 +367,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
Some(sel) => sel.content_bounds(),
None => m.surface.content_bounds(),
};
check_budget(src, h, "layer mask")?;
check_budget(src, h, "layer mask", m.surface.format())?;
}
}
Ok(())
@@ -336,7 +375,7 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`].
/// Refused, with `l` untouched, when a result would take more than [`budget`].
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE)
}
@@ -514,13 +553,124 @@ pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) {
}
/// Normal-blend `top` over `dst` (both straight alpha, same format).
/// Works tile by tile in parallel on `top`'s allocated tiles: fully opaque tiles share their
/// `Arc<Tile>` directly with `dst`, and opaque/transparent pixels on border tiles copy encoded
/// bytes without a whole-region `f32` round trip.
pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) {
use photocraft_color::{SampleType, read_sample};
use photocraft_geom::TileCoord;
use photocraft_raster::Tile;
use rayon::prelude::*;
use std::sync::Arc;
let fmt = dst.format();
let n = fmt.channels();
if n == 0 || !fmt.alpha || top.format() != fmt {
return;
}
let a = n - 1;
if top.default_pixel().get(a).is_none_or(|&ta| ta <= 0.0) {
let top_tiles: Vec<(TileCoord, Arc<Tile>)> = top.tiles().map(|(&tc, t)| (tc, t.clone())).collect();
if top_tiles.is_empty() {
return;
}
let bpp = fmt.bytes_per_pixel();
let sb = fmt.sample.bytes();
let alpha_off = bpp - sb;
let dst_empty_default = dst.default_bytes().iter().all(|&b| b == 0);
let dst_ref = &*dst;
let edits: Vec<(TileCoord, Arc<Tile>)> = top_tiles
.into_par_iter()
.filter_map(|(tc, top_tile)| {
let tb = top_tile.bytes();
let all_opaque = match fmt.sample {
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&u8::MAX)),
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[u8::MAX, u8::MAX])),
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0),
};
if all_opaque {
return Some((tc, top_tile));
}
let all_transparent = match fmt.sample {
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) == Some(&0)),
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) == Some(&[0, 0])),
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) <= 0.0),
};
if all_transparent {
return None;
}
if dst_ref.tile(tc).is_none() && dst_empty_default {
let clean_zero = match fmt.sample {
SampleType::U8 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off) != Some(&0) || px.iter().all(|&b| b == 0)),
SampleType::U16 => tb.chunks_exact(bpp).all(|px| px.get(alpha_off..bpp) != Some(&[0, 0]) || px.iter().all(|&b| b == 0)),
SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) > 0.0 || px.iter().all(|&b| b == 0)),
};
if clean_zero {
return Some((tc, top_tile));
}
}
let mut out_tile = match dst_ref.tile(tc) {
Some(dt) => (**dt).clone(),
None => dst_ref.try_default_tile().ok()?,
};
let mut tp = [0.0f32; 8];
let mut dp = [0.0f32; 8];
for (out_px, top_px) in out_tile.bytes_mut().chunks_exact_mut(bpp).zip(tb.chunks_exact(bpp)) {
match fmt.sample {
SampleType::U8 => {
let ta_b = top_px[alpha_off];
if ta_b == 0 {
continue;
}
if ta_b == u8::MAX {
out_px.copy_from_slice(top_px);
continue;
}
}
SampleType::U16 => {
if top_px[alpha_off..bpp] == [0, 0] {
continue;
}
if top_px[alpha_off..bpp] == [u8::MAX, u8::MAX] {
out_px.copy_from_slice(top_px);
continue;
}
}
SampleType::F32 => {
let ta = read_sample(top_px, SampleType::F32, a);
if ta <= 0.0 {
continue;
}
if ta == 1.0 {
out_px.copy_from_slice(top_px);
continue;
}
}
}
for c in 0..n {
tp[c] = read_sample(top_px, fmt.sample, c);
dp[c] = read_sample(out_px, fmt.sample, c);
}
let (ta, da) = (tp[a], dp[a]);
let oa = ta + da * (1.0 - ta);
if oa > 0.0 {
for c in 0..a {
dp[c] = (tp[c] * ta + dp[c] * da * (1.0 - ta)) / oa;
}
}
dp[a] = oa;
photocraft_raster::encode_pixel(&fmt, &dp[..n], out_px);
}
Some((tc, Arc::new(out_tile)))
})
.collect();
dst.put_tiles(edits);
return;
}
let b = top.content_bounds();
if b.is_empty() {
return;
}
let n = dst.format().channels();
let a = n - 1;
let t = top.read_region(b);
let mut d = dst.read_region(b);
for (dp, tp) in d.chunks_exact_mut(n).zip(t.chunks_exact(n)) {
@@ -664,14 +814,14 @@ fn transform_doc(
let sel = doc.selection.clone();
// The selection outline moves too: price it before anything is built.
if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) {
check_budget(sel.content_bounds(), h, "selection")?;
check_budget(sel.content_bounds(), h, "selection", sel.format())?;
}
if lone {
// A targeted unlinked mask, alpha channel or Quick Mask transforms by itself.
if !h.is_identity() {
let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?;
let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds);
check_budget(src, h, "mask or channel")?;
check_budget(src, h, "mask or channel", surf.format())?;
*surf = match &sel {
Some(sel) => warp_gray_selected(surf, sel, h, interp)?,
None => warp_gray(surf, h, interp)?,
@@ -737,29 +887,78 @@ mod tests {
s
}
/// The content bounds of the active layer's pixels.
fn active_bounds(s: &Session) -> Rect {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
}
// ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ----
// ---- Memory budget (#3773, #7807). Tests run with a 20 MB budget (2²⁰ px of 32-bit CMYK with
// alpha), so nothing here comes near the real one. ----
/// Memory Usage's default, in bytes.
const DEFAULT_BUDGET: u64 = (crate::prefs::DEFAULT_MEMORY_USAGE_MB as u64) << 20;
/// Scales of 10⁷ and past are refused by the real budget too, whatever the axis or sign.
#[test]
fn huge_transform_scales_exceed_the_production_budget_before_clipping() {
let src = Rect::new(0, 0, 24, 16);
for scale in [1e7, 1e28, 1e306] {
for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] {
let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]);
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused");
assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA8, DEFAULT_BUDGET).is_err(), "{sx} x {sy} must be refused");
}
}
}
/// A 240000 px wide stretch, placed far from the canvas but within the warp's reach, fits.
#[test]
fn an_ordinary_transform_remains_within_the_production_budget() {
let src = Rect::new(0, 0, 24, 16);
let h = Homography([10_000.0, 0.0, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]);
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok());
let h = Homography([10_000.0, 0.0, 800_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]);
assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA8, DEFAULT_BUDGET).is_ok());
}
/// The warp places pixels only within ±2²⁰ px ([`WARP_REACH`]): a result that fits Memory
/// Usage but would reach past it, by its size or by where it lands, is refused, not clipped.
#[test]
fn a_result_past_the_warps_reach_is_refused_not_clipped() {
let src = Rect::new(0, 0, 24, 16);
let check = |h: [f64; 9]| check_budget_with_limit(src, &Homography(h), "layer", PixelFormat::RGBA8, DEFAULT_BUDGET);
// 2000016 x 18 px, 138 MB of 8-bit RGBA: from x = 0 it would be cut at 2²⁰; centred it fits.
let err = check([83_334.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).unwrap_err().to_string();
assert!(err.contains("would reach past ±1048576 px") && err.ends_with("the document was not changed"), "{err}");
assert!(check([83_334.0, 0.0, -1_000_000.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_ok());
// A small layer moved past the reach, either way on either axis.
for (dx, dy) in [(1_100_000.0, 0.0), (-1_100_000.0, 0.0), (0.0, 1_100_000.0), (0.0, -1_100_000.0)] {
assert!(check([1.0, 0.0, dx, 0.0, 1.0, dy, 0.0, 0.0, 1.0]).is_err(), "{dx}, {dy}");
}
// Through the command: nothing moves, nothing is lost.
let mut s = session();
let st = s.active().unwrap();
let (before, steps) = (st.doc.clone(), st.history.past_len());
let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 2_000_000, 0]})).unwrap_err().to_string();
assert!(err.contains("the transformed layer \"Layer 1\" would reach past ±1048576 px"), "{err}");
let st = s.active().unwrap();
assert!(st.doc == before && st.history.past_len() == steps, "the document is untouched");
}
/// #7807: the stitched layer's 90 % and 15°, a 37708 x 35259 px result that the old 2³⁰ px limit
/// refused, fits the default Memory Usage at 8 bits (5072 MB); at 16 bits it needs Memory Usage
/// raised, and the message says so.
#[test]
fn a_gigapixel_layer_transforms_within_memory_usage() {
let src = Rect::new(0, 0, 35_020, 31_171);
let r = [0.0, 0.0, 35_020.0, 31_171.0];
let h = relative_matrix(&json!({"scaleX": 90, "scaleY": 90, "rotate": 15}), r).unwrap().unwrap();
assert_eq!(warp_output_size(src, &h), Some((37_708, 35_259)), "the report's result, past the old limit");
assert!(check_budget_with_limit(src, &h, "layer \"Layer 1\"", PixelFormat::RGBA8, DEFAULT_BUDGET).is_ok());
let err = check_budget_with_limit(src, &h, "layer \"Layer 1\"", PixelFormat::RGBA16, DEFAULT_BUDGET).unwrap_err().to_string();
assert!(err.contains("(10144 MB), more than the 8192 MB Memory Usage allows (Preferences › Performance)"), "{err}");
assert!(check_budget_with_limit(src, &h, "layer", PixelFormat::RGBA16, 2 * DEFAULT_BUDGET).is_ok());
// A format without alpha is priced as the warp makes it, with alpha.
let rgb8 = PixelFormat::new(PixelFormat::RGBA8.mode, PixelFormat::RGBA8.sample, false);
assert!(check_budget_with_limit(src, &h, "layer", rgb8, 5071 << 20).is_err());
}
/// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10.
@@ -780,15 +979,22 @@ mod tests {
json!({"matrix": [k, 0, 0, 1, -10.0 * k, 0], "interpolation": "nearest"})
}
/// A stretch past the budget is refused with the limit named, and the document, its revision
/// and History stay as they were; within the budget the same stretch applies unclipped.
#[test]
fn a_scale_past_the_pixel_budget_is_refused_and_changes_nothing() {
let mut s = column();
let st = s.active().unwrap();
let (before, revision, steps) = (st.doc.clone(), st.revision, st.history.past_len());
// 200000 x 10 px of output: twice the test budget.
let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string();
assert!(err.starts_with("not enough memory for transforming pixels"), "{err}");
assert!(err.contains("would be 200002 x 10 px") && err.ends_with("the document was not changed"), "{err}");
// 1000000 x 10 px of 8-bit RGBA output: about twice the test budget.
let err = s.execute("edit.transform", stretch(1e6)).unwrap_err().to_string();
assert!(err.starts_with("too large to transform: the transformed layer \"Layer 1\""), "{err}");
assert!(
err.contains("would be 1000002 x 10 px (39 MB), more than the 20 MB Memory Usage allows") && err.ends_with("the document was not changed"),
"{err}"
);
// Nothing was allocated, so nothing ran short (#7807).
assert!(!err.contains("not enough memory"), "{err}");
let st = s.active().unwrap();
assert!(st.doc == before, "the document is untouched");
assert_eq!((st.revision, st.history.past_len()), (revision, steps));
@@ -806,7 +1012,7 @@ mod tests {
let dot = s.active().unwrap().active_layer.unwrap();
s.edit("paint", |doc, _| {
doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 11, 13), &[0.0, 1.0, 0.0, 1.0]);
// Widen the column to 8 px (x 2..10), so it alone needs 400002 x 10 px.
// Widen the column to 8 px (x 2..10), so it alone needs 800002 x 10 px (31 MB).
doc.layer_mut(wide).unwrap().surface_mut().unwrap().fill_rect(Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
@@ -817,7 +1023,7 @@ mod tests {
let st = s.active().unwrap();
assert!(matches!(&st.doc.layer(group).unwrap().content, LayerContent::Group(g) if g.children.len() == 2));
let (before, revision) = (st.doc.clone(), st.revision);
let k = 5e4;
let k = 1e5;
assert!(s.execute("edit.transform", json!({"layer": group.0, "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err());
let st = s.active().unwrap();
assert!(st.doc == before && st.revision == revision, "nothing changed");
@@ -829,6 +1035,8 @@ mod tests {
assert!(&l == st.doc.layer(group).unwrap(), "no child was transformed");
}
/// Only the result's size is priced: a small layer moved 900000 px away (within the warp's
/// reach) and back keeps every pixel.
#[test]
fn moving_a_small_layer_far_off_the_canvas_is_within_budget() {
let mut s = session();
@@ -838,28 +1046,31 @@ mod tests {
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "and back, every pixel kept");
}
/// Each priced at its own format, as the warp makes it: an 8-bit outline, mask or channel with
/// alpha is 2 bytes a pixel, so a canvas-high column of it stretched 10⁶ times (34 MB) is over
/// the test budget.
#[test]
fn selections_masks_and_channels_have_the_same_budget() {
// The selection outline of a layer transform.
let mut s = column();
s.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 0, "width": 1, "height": 16})).unwrap();
let before = s.active().unwrap().doc.clone();
let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string();
let err = s.execute("edit.transform", stretch(1e6)).unwrap_err().to_string();
assert!(err.contains("transformed selection would be"), "{err}");
// Transform Selection.
let err = s.execute("select.transformSelection", stretch(2e5)).unwrap_err().to_string();
let err = s.execute("select.transformSelection", stretch(1e6)).unwrap_err().to_string();
assert!(err.contains("transformed selection would be"), "{err}");
assert!(s.active().unwrap().doc == before);
// A targeted alpha channel.
let mut s = column();
s.execute("channel.new", json!({})).unwrap();
s.edit("paint channel", |doc, _| {
doc.channels[0].surface.fill_rect(Rect::new(10, 2, 11, 10), &[1.0]);
doc.channels[0].surface.fill_rect(Rect::new(10, 0, 11, 16), &[1.0]);
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let mut p = stretch(2e5);
let mut p = stretch(1e6);
p["target"] = json!({"channel": 0});
let err = s.execute("edit.transform", p).unwrap_err().to_string();
assert!(err.contains("transformed mask or channel would be"), "{err}");
@@ -1539,4 +1750,32 @@ mod tests {
assert_eq!((x0.round(), y0.round(), x1.round(), y1.round()), (-10.0, 30.0, 110.0, 70.0));
assert_eq!((sh.density, sh.feather), (1.0, 20.0), "the transform keeps the soft settings");
}
/// A stroked soft shape's frame is the hard shape's rendered frame for every stroke alignment:
/// a centred stroke reaches half its width past the path, not its full width; both stop at
/// the canvas edge.
#[test]
fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() {
for (align, reach, x) in [("center", 5, 50), ("inside", 0, 50), ("outside", 10, 50), ("center", 5, -20)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
let stroke = json!({"width": 10, "color": "#ff0000", "align": align, "join": "round"});
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [x, 50, 60, 40], "fill": "#000000", "stroke": stroke})).unwrap()["layer"]
.as_u64()
.unwrap();
let id = photocraft_doc::LayerId(id);
let boxes = |s: &Session| {
let st = s.active().unwrap();
let l = st.doc.layer(id).unwrap();
(transform_bounds(&st.doc, l), transform_frame(&st.doc, l), crate::snap::layer_rect(&st.doc, id))
};
let hard = boxes(&s);
// Hand-computed: the path is x..x + 60 x 50..90, the stroke reaches `reach` px past it.
assert_eq!(hard.0, Rect::new((x - reach).max(0), 50 - reach, x + 60 + reach, 90 + reach), "{align} at {x}: the hard shape's frame");
s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 8})).unwrap();
assert_eq!(boxes(&s), hard, "{align} at {x}: Density and Feather leave the frame");
s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 8})).unwrap();
assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame");
}
}
}
+34 -6
View File
@@ -108,7 +108,8 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> {
}
/// Validates text metrics (size, tracking, leading, baseline shift, scales) that can otherwise
/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold.
/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold,
/// and the superscript/subscript `baseline`, so a typo is an error rather than a reset to Normal.
pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
if let Some(v) = p.get("size") {
match v.as_f64() {
@@ -138,6 +139,11 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
_ => return Err(format!("baselineShift must be between -{max} and {max} pt")),
}
}
if let Some(v) = p.get("baseline").filter(|v| !v.is_null())
&& !matches!(v.as_str(), Some("normal" | "superscript" | "subscript"))
{
return Err("baseline must be \"normal\", \"superscript\" or \"subscript\"".into());
}
for key in ["horizontalScale", "verticalScale"] {
if let Some(v) = p.get(key) {
match v.as_f64() {
@@ -365,12 +371,17 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
hit(true);
}
if let Some(v) = p.get("baseline").and_then(Value::as_str) {
s.baseline = match v {
"superscript" => FontBaseline::Superscript,
"subscript" => FontBaseline::Subscript,
_ => FontBaseline::Normal,
let baseline = match v {
"superscript" => Some(FontBaseline::Superscript),
"subscript" => Some(FontBaseline::Subscript),
"normal" => Some(FontBaseline::Normal),
_ => None,
};
hit(true);
// An unknown value keeps the style (the commands reject it in `check_size_tracking`).
if let Some(b) = baseline {
s.baseline = b;
hit(true);
}
}
if let Some(Value::Object(m)) = p.get("features") {
for (tag, v) in m {
@@ -1617,6 +1628,23 @@ mod tests {
assert_eq!(sizes(&s), [(5, 40.0), (1, 12.0)]);
}
/// A misspelt `baseline` is an error and keeps superscript, instead of resetting it to Normal.
#[test]
fn unknown_baseline_is_rejected_and_keeps_the_style() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "x2", "size": 30})).unwrap()["layer"].as_u64().unwrap();
s.execute("type.setStyle", json!({"layer": id, "range": [1, 2], "baseline": "superscript"})).unwrap();
let before = text_layer(&s, id).runs;
for b in [json!("superscipt"), json!("Superscript"), json!(1)] {
assert!(matches!(s.execute("type.setStyle", json!({"layer": id, "baseline": b})), Err(EngineError::BadParams { .. })), "{b}");
assert!(s.execute("type.edit", json!({"layer": id, "baseline": b})).is_err(), "{b}");
assert!(s.execute("type.create", json!({"x": 0, "y": 10, "text": "a", "baseline": b})).is_err(), "{b}");
}
assert_eq!(text_layer(&s, id).runs, before);
s.execute("type.setStyle", json!({"layer": id, "baseline": "normal"})).unwrap();
assert!(text_layer(&s, id).char_runs().iter().all(|r| r.style.baseline == FontBaseline::Normal));
}
fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> {
let t = text_layer(s, id);
let mut out = Vec::new();
+30 -7
View File
@@ -527,16 +527,39 @@ fn style_font_missing(fonts: &mut photocraft_text::FontDb, style: &photocraft_do
true
}
/// Font families used by type layers that the font database cannot supply.
/// The document's type layers, inside groups too.
fn type_layers(doc: &Document) -> Vec<&TextLayer> {
doc.walk()
.into_iter()
.filter_map(|(_, _, l)| match &l.content {
LayerContent::Text(t) => Some(t),
_ => None,
})
.collect()
}
/// Whether `doc` has a type layer. Only then does checking its fonts need the shared text engine,
/// whose first use waits for the installed-font scan started at launch: minutes on some machines
/// (#7332).
pub fn has_type_layers(doc: &Document) -> bool {
doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Text(_)))
}
/// Font families used by type layers that the font database cannot supply. A document without
/// type returns at once, without touching the shared text engine: the shell asks this on the UI
/// thread for every file it opens, and waiting there for the font scan froze the window on the
/// first open after launch (#7332).
pub fn missing_fonts(doc: &Document) -> Vec<String> {
let layers = type_layers(doc);
if layers.is_empty() {
return Vec::new();
}
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<String> = Vec::new();
for (_, _, l) in doc.walk() {
if let LayerContent::Text(t) = &l.content {
for r in t.char_runs() {
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
out.push(r.style.font_family);
}
for t in layers {
for r in t.char_runs() {
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
out.push(r.style.font_family);
}
}
}
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff. Show more