Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 9331bc21b0 fix(type): type with a missing font keeps its pixels through transforms (#3617, #3556)
A type layer whose font is not installed no longer re-renders in the
fallback face when it is transformed or resized with the canvas. Like
Photoshop, the layer keeps the pixels it was drawn with (they transform
as pixels, through Free Transform, Image Size and canvas rotation) until
its text is edited; its PSD TySh keeps the file's text data byte for byte
with only the transform updated. Moves already kept the pixels.

Missing-font detection also accepts a stored PostScript name that names
an installed face, so such a run is neither reported missing nor kept as
pixels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:48:54 -04:00
Brandon ThomasandClaude Opus 5.5 184c226a76 Fix main: wasm size cap, Windows Export As test, filter preview window (scope and a crash), CLI duplicate font warnings (#4147)
* test(ui-egui): Export As artboards test accepts the Windows path separator

The test added in #3909 compared the second file's path with a literal
'/', but the exporter joins the sibling name with the platform separator,
so Windows CI saw '/out\boards-2-Capsule.jpg' and failed.

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

* fix(ui): only Filter menu dialogs get the in-dialog preview window

#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.

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

* build(web): opt-level z for large cold crates in the wasm release profile

The browser wasm sat at the 24 MiB gate. The document model, file
parsers (psd, format, affinity), serde_json, usvg and naga are off the
per-frame and per-pixel paths; opt-level "z" for them takes the
release wasm from 25,186,233 to 24,829,840 bytes (-356 KB) with no
behaviour change.

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

* fix(cli): run prints each missing-font warning once

Since #3630 the headless open and save also report missing fonts, so
`run <file> --out` and `run --cmd ... --out` printed the same warning
two or three times and missing_typeface_is_reported_by_run_info_and_convert
failed on main. Warnings from the open and the save now go through the
same already-reported set as the per-command ones.

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

* fix(ui): the filter preview window never uploads a texture past the GPU limit

With low-resolution previews off, the preview window from #3877 uploaded
the whole document as one texture. egui panics on a texture bigger than
max_texture_side, so opening a filter dialog on a large image crashed
(full_res_preview::filter_dialog_previews_at_full_resolution_when_low_res_is_off).
The texture is now reduced to fit; the view maps through UV fractions,
so it still lines up.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:48:28 -04:00
Brandon ThomasandClaude Opus 5.5 2db6b7d8f9 Release 0.8.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:11:02 -04:00
Brandon ThomasandClaude Opus 5.5 3d6050b931 Release workflow: pass the Azure signing secrets so Windows builds are signed (#3794)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:08:34 -04:00
9e623e7a45 i18n: add Hungarian (hu) UI translation (#3831)
* i18n(cs): drop duplicate catalog entries

The Czech catalog listed Tool, Clone Stamp, History Brush, Dodge, Burn,
Sponge and Stroke Path… twice, which fails `cargo xtask i18n-coverage`
and the bundled_catalogs_are_consistent test. Keep one row each; for
Stroke Path… keep the later row, the one the loader already used.

* test(crop): pin the UI language to English in crop_default_mode

The test queries widgets by their English labels ("Ratio", "Swaps height
and width") but its harness followed the system locale, so it failed on a
machine whose locale has a registered catalog. Set interface.language to
"en" in the harness, as paint_bucket_all_layers and export_as_scaling do.

* i18n: add Hungarian (hu) UI translation

A complete Hungarian catalog (hu.tsv, 3908 entries: 3829 plain, 50
contextual, 21 plural, 8 command-id overrides) registered with the
complete-menu languages; hu-HU locales resolve to it.

Plural rule: plural_none. A Hungarian count takes the singular noun
("3 réteg"), so every @plural entry has one form.

Clean room: written from the plain meaning of the English labels with
ordinary Hungarian image-editing vocabulary; no proprietary localisation
resource, string table or product UI was used.

Also listed in the coverage test, LICENSE-translations.txt,
ATTRIBUTION.md and docs/localization.md, with tag, system-locale and
plural tests.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:07:17 -04:00
b3309e27d7 i18n(tr): add Turkish UI translation catalog (#3809)
Add Turkish (tr) translation catalog covering 100% of measured UI strings (1,727/1,727 keys, 3,731 catalog entries).

- Register Turkish language in crates/ui-egui/src/i18n/mod.rs with complete_menus: true and plural_none
- Add tr.tsv translation catalog
- Add negotiation and coverage tests in system.rs, mod.rs and i18n_coverage.rs
- Add MIT OR Apache-2.0 notice in LICENSE-translations.txt
- Update docs/localization-parity.md

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:01:46 -04:00
+3 67e4b8a499 feat: Alt+Cancel → Reset for opt-in command dialogs (#3871)
* Update .gitignore and add UI Dialogs documentation

- Added .specstory, .private, and .vscode to .gitignore to exclude additional files from version control.
- Introduced a new documentation file, ui-dialogs.md, detailing the architecture, rules, and usage of modal and modeless dialogs in the egui shell, mirroring Photoshop's functionality.

* Enhance UI Dialogs documentation in ui-dialogs.md

- Expanded the section on command routing and dialog types, clarifying the roles of various dialog kinds and their associated modules.
- Improved descriptions of routing markers and private keys, ensuring better understanding of the dialog architecture and usage.
- Updated the menu command entry details to reflect changes in module organization and functionality.

* Implement Alt+Cancel (Reset) functionality for various UI dialogs

- Introduced a new module `dialog_alt_reset` to handle the Alt+Cancel behavior, allowing users to reset dialog fields to their original values while keeping the dialog open.
- Updated multiple dialog modules to support this feature, including adjustments for color pickers, sizing, and guide layouts.
- Added tests to ensure that the reset functionality correctly restores original values and maintains dialog state.
- Enhanced documentation to reflect the new Alt+Reset behavior and its implementation details across different dialog types.

* Update manual UI testing command to include build timing summary

- Modified the `manual-ui-testing` command in `.cargo/config.toml` to include the `--timings` flag, enabling the generation of a build timing summary after compilation and linking.
- Refactored the `reset_fields` function in `filter_dialog.rs` for improved readability by consolidating the retrieval of the `spec` variable into a single line, enhancing code clarity.

* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119) (#3125)

* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

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

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

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

* Shortcuts: Ö / # are the brush keys on a German layout (#3109)

Photoshop's German shortcuts step the brush size with Ö / # and the hardness
with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries
Alt on the Mac and Windows, which the shortcut match rejects). egui can't name
ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+#
types ' and arrived as Quote.

shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and
Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of
shortcuts::handle (unless a text field has focus), so Liquify's and the
Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took
from the physical key or from that very text change, and never digits.

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

* Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113)

A drag that started on a layer's mask thumbnail dragged the whole row, and
dropping it on the Trash deleted the layer. Now a drag from the pixel mask
thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer
Mask" ghost and starts neither a layer reorder nor a cross-document transfer.
Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before
removing?" with Apply (layer.layerMask.apply, greyed out where it can't run),
Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes
the targeted mask at once (#2197).

The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm
applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps.
Two new strings in all 16 catalogs.

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

* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119)

A drag that started on a row's vector mask thumbnail picked up the whole
layer row, so dropping it on the Trash deleted the layer. The mask drag
from #3113 now covers both thumbnails: a vector mask dropped on the Trash
opens Photoshop's "Delete the vector mask?" (OK / Cancel), and OK runs
layer.vectorMask.delete. Clicks on the thumbnail are unchanged.

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

* Brush keys: layout table plus bracket-position fallback for every layout (#3109)

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Interface: an Enable Animations preference (#3273) (#3276)

Preferences › Interface › Enable animations (interface.enableAnimations, on by
default). Off, prefs_ui::sync_animations sets egui's animation_time to 0 and
scroll_animation to none in both the dark and light styles, so menus, popups
and collapsing sections change at once and scroll areas jump. It is re-applied
each frame, so a theme change keeps it. Translated in all 16 catalogs; the
scorecard row UI-217-10 goes to partial.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267)

* automation: export layers, data sets and CSVs through the granted roots (#3216) (#3230)

* automation: doc.place reads a file through the read root (#2810)

file.placeEmbedded takes an ambient path, so automation refuses it and an
agent could not place a file into the open document at all. doc.place
resolves the path beneath the read root the way doc.open does and hands the
bytes it already read to the public file_cmds::place_bytes, so the existing
cap-std rules stay the only path rules: no new scope logic, and the ambient
command keeps its refusal. Trusted-local callers still run the command.

* automation: export layers, data sets and CSVs through the roots (#3216)

file.export.layersToFiles and file.export.dataSetsAsFiles take an ambient
dir, file.import.variableDataSets an ambient path, so all three are refused
in automation even for files inside the granted roots, and an agent could
not export a single file or a data set at all.

Both halves already existed: the engine writes bytes it has just encoded
(save_doc is encode + write_file, and the data-set exporter encodes with
photocraft_io::export before writing), and the automation side already
holds a write capability — AuthorizedWorkspace::export_sequence creates the
directory, refuses to overwrite, accepts only single-component names and
removes every file it created when a step fails, which is what Render Video
uses. So each command body moves behind the same sink callback
render_video_with takes: the command keeps writing the bytes it is handed
to its dir, the new methods write the very same bytes through the root, and
the two implementations cannot drift.

---------

Co-authored-by: switch87 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* Type: fall back to installed Khmer, Lao and Burmese fonts (#218) (#3259)

Text in these scripts drew .notdef boxes although the OS had a font for
them. Detection was never the problem: the system font scan loads them,
and asking for one by name shapes correctly. The fallback stack is a
list of family names intersected with the installed fonts, and it had no
entry for any of the three, so an installed Khmer, Lao or Burmese font
could not be reached from a Latin-only face.

- photocraft-text: KHMER_FAMILIES, LAO_FAMILIES and MYANMAR_FAMILIES join
  fallback_candidates next to THAI_FAMILIES; the web build's served-font
  scripts gain Khmr, Laoo and Mymr (with their ranges and languages).
- photocraft-ui-egui: the UI's lazy loader had one non-CJK slot (Thai).
  It becomes a LazyScript slot table, so the three scripts are data
  rather than a third and fourth copy of the same state machine.
- Tests assert detection and selection separately, so a future failure
  names its layer, and skip when a machine lacks the script's font.

Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* Curves: pencil mode, Smooth and Edit points (#3088) (#3108)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* fix(io): smooth PSD gradients no longer overshoot their stops (#3028) (#3285)

The Catmull-Rom tangents used for Smoothness are derived from the
neighbouring stops, so a segment between two equal-coloured stops (a
flat plateau) picked up a non-zero tangent and dipped below its own
colour. In the Gradient Map from #3028 the 27%-53% plateau (#1c1c1c)
rendered about 19/255 too dark.

Bound each interpolated channel to the range of the segment's two end
colours. Smoothness still bends the ramp, but never invents a colour
outside the segment, so plateaus stay exactly flat.

The regression test uses the four stop positions and grey values from
the issue (synthetic, no artwork) and fails without the clamp.

* Codecs: decode PNM straight into the budgeted output buffer (#2938) (#3291)

Limits::max_alloc budgets the decoded pixel buffer, but the PNM decoder
collected one u32 per sample first (4x an 8-bit image, plus a u16 copy
for 16-bit) and PFM built a Vec<f32> beside the output bytes. Samples are
now rescaled and written straight into the native-endian output buffer
(reserved fallibly), so the only header-sized allocation is the one
Limits::check already charged.

Co-authored-by: Claude Opus 5.5 <[email protected]>

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) (#3115)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Format: admit the declared .pcraft canvas before loading it (#2935) (#3295)

* Format: admit the declared .pcraft canvas before loading it (#2935)

A .pcraft manifest's canvas size was copied into the Document unchecked, so
a few bytes of JSON could declare a 300000x300000 (or u32::MAX) canvas that
the CPU reduction, thumbnails and display textures then work against in
proportion to its area.

The loader now refuses an empty canvas, a side above 300000 (Photoshop's PSB
limit, as for PSD and file.new) and an area above 4 gigapixels, right after
reading the manifest and before any tile or blob is decoded. Both limits are
LoadOptions fields so trusted callers can change them.

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

* Format: admit every canvas the app can create on .pcraft load (#2935)

The default pixel budget was 2^32 (65536 x 65536), but file.new, Canvas
Size, Image Size with Resample off and PSB import all reach 300000 x
300000, so a document a user could create and save would not reopen.
The default is now the full 300000^2 PSB canvas; the side limit still
refuses anything larger, and callers handling untrusted files can lower
either budget. Loading stays proportional to the file: layers keep only
the tiles the bundle holds, so a huge declared canvas allocates nothing
for its area.

A compile-time assert in document_preset_cmds ties file.new's
MAX_DIMENSION to the loader's defaults.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

* fix(format): save the target file name in native bundle manifests (#3312) (#3319)

* Levels: black, gray and white point eyedroppers (#3116) (#3128)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

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

* Levels: black, gray and white point eyedroppers (#3116)

Image > Adjustments > Levels and a Levels adjustment layer's Properties
panel get Photoshop's Set Black/Neutral Gray/White Point eyedroppers.
Black/white move each channel's input black/white to the sample, gray
sets each channel's midtone gamma so the sample turns neutral at its
luma (Grayscale: composite input, no gray). The engine takes the same
`eyedropper` param as Curves on image.adjustments.levels,
layer.newAdjustmentLayer.levels and layer.setAdjustment.

On a smart object, image.adjustments.levels/curves with `eyedropper`
now record the resolved adjustment as the smart filter instead of the
raw sample, which re-rendered as an identity (#3127).

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Puppet Warp: the mesh follows the object outline (#3105) (#3117)

The mesh was every square grid cell touching the layer's opaque pixels:
a staircase up to a cell outside the shape, and shapes closer than about
a cell shared cells, so pinning one dragged the other along.

build_mesh now traces the outline of the opaque region grown (or shrunk)
by Expansion from a signed distance field, simplifies it (Douglas-Peucker,
corners kept), splits its sides to the Density spacing, fills the inside
with a triangular lattice and triangulates the points (Bowyer-Watson
Delaunay, outline sides recovered by midpoint splits), keeping the
triangles inside the outline. Separate shapes get separate meshes, so an
unpinned one stays put, as in Photoshop. Vertices are numbered in reverse
Cuthill-McKee order to keep the solver's banded factor narrow, and the
density's spacing counts the grown region so a large Expansion on a small
layer no longer builds tens of thousands of vertices.

Scorecard TOOL-214-6 done; docs/gaps.md G7 updated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Clone Source: W/H link and overlay blend modes (#3298) (#3310)

The Clone Source panel gets Photoshop's W/H chain (Maintain aspect
ratio): with it on, a new W or H scales the other by the same factor.
cloneSource.set takes "linked" and cloneSource.list reports it.

The overlay's Darken, Lighten and Difference modes were stored but
never drawn. draw_clone_preview now blends the source with the
composite under the brush (clone_source::overlay_blend), so Difference
goes black where source and image line up.

Co-authored-by: Claude Opus 5.5 <[email protected]>

* Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) (#3133)

Filter > Vanishing Point now opens a dialog mode on the canvas instead of
the generic parameter dialog:

- Create Plane (C): four clicks, in any order, make a plane (corners are
  normalised clockwise from the top-left, the order paste expects).
- Edit Plane (V): drag corner nodes, or edge nodes to slide an edge along
  the plane's own perspective; click a plane to select it, Delete removes it.
- The plane grid is drawn at the Grid Size, in the accent colour when the
  plane is valid, warning yellow when its perspective is doubtful and red
  (outline only) when the quad isn't convex (VpPlane::check).
- With an image on the clipboard the selected plane previews it pasted in
  perspective; drag to move it, Width sets its size, New Layer renders it
  on a new layer. OK / Enter runs filter.vanishingPoint with the planes and
  the paste as one history step; the control channel drives the same
  session through {"ui": {...}}.

The engine now rejects a drawn plane whose corners don't form a convex
quad (bow-tie, dented) instead of warping along it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Let `[` shrink the brush to 1 px (#3229) (#3332)

`[` steps the brush size down by 25 %, rounded: from 2 px that is 1.6,
which rounds back to 2, so the key never reached 1 px and pixel artists
had to type the size. `]` already grows by at least a pixel; `[` now
shrinks by at least a pixel the same way, still never below 1 px.

* MCP: command_batch says it is not atomic, and the docs explain recovery (#3338)

Agents read the tool description, not docs/mcp.md, and the command_batch
description only said it stops at the first error. It now also says the
steps run in order and that earlier successful steps stay applied: there
is no rollback. docs/mcp.md gives the stop_on_error default and a recovery
recipe: inspect with doc_inspect, undo with edit.undo once per history
entry (undo does not reverse files written to disk), or experiment on a
copy made with image.duplicate or history.newDocument. A test checks the
listed description.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Valentin Klein <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Gert Pellin - GPE <[email protected]>
Co-authored-by: switch87 <[email protected]>
Co-authored-by: RaccoonPie-314 <[email protected]>
Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Sir Starch <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: myangaa1 <[email protected]>
Co-authored-by: KnottyDyes <[email protected]>
2026-10-12 10:52:19 -04:00
zenjabbaandzenjabba 9163afbdcf Verify PngSuite archives and cached corpus files (#3973)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 10:45:05 -04:00
f4fa53140c ops: count selection masks in history pixel budget (#3614)
* ops: count selection masks in history pixel budget

* test: expect unclipped HDR levels in float documents

* web: optimize engine code for the wasm size budget

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:43:07 -04:00
Korg1ch dc35ce7630 fix(linux): avoid duplicate AppImage desktop launcher (#3623) (#3641) 2026-10-12 10:35:47 -04:00
Valentin KleinandClaude Opus 5.5 a8b52f1dbf Codecs: enable JPEG and CCITT fax compression for TIFF (#3712) (#3727)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:34:45 -04:00
Valentin KleinandClaude Opus 5.5 019a391f40 Codecs: decode only the requested EXR part; Cryptomatte uses the cumulative cap (#3721) (#3722)
read_layer decoded every part and picked one; use a first-valid-layer reader
that declines all other parts. Cryptomatte now goes through read_meta and
read_layer. Enable exr/rayon on native targets.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:33:21 -04:00
null 00000111 0b935da882 HEIF: move the heic-rs pin from 0.1.1 to 0.1.4 (#3637)
heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing:
four in release builds (a uuid box too small for its extended type, a
block starting right of the picture, a conformance window larger than the
picture, and a reference row below the picture) and two with overflow
checks on (a PCM bit depth above the sample bit depth, a slice QP out of
range). The API, MSRV and output are unchanged.

Both pins move together: crates/heif and the codecs fuzz target, which
calls heic-rs directly. guarded() stays as the backstop it was meant to be.
2026-10-12 10:22:11 -04:00
null 00000111 423421a879 Fuzz: ignore the same build leftovers in every fuzz crate (#3585)
crates/raw/fuzz had no .gitignore, crates/psd/fuzz didn't ignore
Cargo.lock and crates/format/fuzz didn't ignore coverage/, so running
those fuzz targets left untracked files in the checkout. All five fuzz
crates now ignore target/, corpus/, artifacts/, coverage/ and Cargo.lock,
as codecs and affinity already did.
2026-10-12 10:19:06 -04:00
73a56d7603 Format: overflow-safe blob decode and bounds-checked ZIP reads (#3486) (#3501)
- store: `max_blob_bytes as u64 + 1` overflowed for usize::MAX (debug
  panic; release take(0), so every blob failed). The blob read and the
  tile decode now share decompress_bounded(), which saturates.
- zip: ZipReader::read sliced `data_offset..data_offset + compressed`
  unchecked although Entry is pub + Clone; a foreign entry is now a
  Corrupt error.
- store: Collect::tile reuses tile_le(); Object::content() gives the
  stored bytes once (existing_object_is_valid no longer builds them twice).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 10:08:20 -04:00
fd21e73411 Format: check pattern sizes on load (#3478) (#3484)
* Format: check pattern sizes on load (#3478)

A .pcraft pattern's width/height came straight from the manifest, so a
1 KB file could declare a 65535x65535 pattern without tiles; painting it
allocated width*height*16 bytes (abort) and the engine's u32 size math
overflowed.

- format: reject patterns 0 px on a side (Corrupt) or above the PSD
  reader's 30 000 px edge (LimitExceeded).
- doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26,
  a 1 GiB f32 RGBA working copy).
- compose/gpu: patterns too large to paint paint nothing instead of
  allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan).
- engine: brush.texturePattern refuses them; average_color uses u64 math
  and a bounded corner.

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

* gpu: skip unpaintable patterns in the Bevel texture too

The Bevel & Emboss texture element uploaded the pattern through
pattern_rgba, which now returns no pixels for a pattern too large to
paint; filter it like the other GPU pattern lookups.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 10:06:16 -04:00
HEOJUNFOandClaude Opus 5.5 bb17dd555c Color: convert input-only embedded profiles to the working space on open (#3876) (#3900)
An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:57:11 -04:00
a58b6f3d9e Mix filtered selection edges with premultiplied alpha (#3895)
* Mix filtered selection edges with premultiplied alpha

Co-authored-by: Codex <[email protected]>

* Update stylize kernel comparison for alpha-aware selection mixing

Co-authored-by: Codex <[email protected]>

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:52:59 -04:00
8072636e0f Count video footage in history memory budgets (#3894)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:46:59 -04:00
FredApps 094c4f30e4 CMS tests: validate profile-specific CMYK black endpoints (#3890)
Retain the relative paper-white requirement and round-trip checks while validating finite, bounded endpoints and darker full-K ink.

Add an encoded and reparsed AToB1 regression with an independently calculated 100/87/74/61/48 lightness ramp, plus invalid endpoint cases.

Signed-off-by: FredApps <[email protected]>
2026-10-12 09:43:34 -04:00
7958dc63fb Layers: Merge Group for a selected group (#3832) (#3888)
* Layers: Merge Group for a selected group (#3832)

With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the
Layers panel's right-click menu offers Merge Group instead of Merge Down, as in
Photoshop. The new layer.mergeGroup command flattens the group's visible contents
into one pixel layer in its place, named after the group, keeping its opacity, blend
mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran
Merge Down and merged the whole group into the layer below it.

* i18n: Vietnamese row for Merge Group

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:42:58 -04:00
4010630c98 Preserve surviving channel targets and visibility on deletion (#3887)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:32:37 -04:00
76fa06dbd5 Merged and stamped layers keep their content bounds in CMYK and Lab (#3886)
Merging or stamping converts the straight-RGBA composite into the
document's colour model. Transparent black converts to K = 100% in CMYK
and neutral a/b in Lab, so those pixels no longer equal the surface's
default pixel, prune() keeps them, and the layer's content bounds span
everything that was rendered: the whole canvas for Stamp Visible, Stamp
Down and Copy Merged, and for Merge Layers, Merge Visible and Merge Down
whenever an input may draw anywhere (a fill layer, even a masked one).

Fully transparent pixels are now written as the default pixel in both
conversion paths: photocraft_compose::flatten_to_surface (stamps, Copy
Merged) and engine pixels::composite_layers (merges). Only formats with
alpha are affected; over a Background everything stays opaque.

Tests: engine merge_transparent_bounds (Merge Layers, Merge Visible over
a transparent document, with a masked fill layer and onto a Background,
Stamp Visible, Stamp Down, undo) in RGB/Gray at 8/16/32 bit and CMYK/Lab
at 8/16 bit, and a compose unit test for flatten_to_surface. The masked
fill layer test fails on main (merged layer = full canvas in CMYK).

Co-authored-by: dgliwa <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 09:28:37 -04:00
73ed08ddbe PSD type: read styles from the run's sheets and place text at its Txt2 frame (#3795, #1317) (#3819)
* PSD type: resolve paragraph and character styles from the run's own sheets (#3795)

apply_engine_data filled a run's missing attributes only from the Normal
sheets (TheNormalParagraphSheet / TheNormalStyleSheet). A run that takes
its alignment or size from a named sheet (by Parent index or Name), or
from its paragraph's DefaultStyleSheet, was read with Normal's values, so
a centred paragraph style came back Left and starting an edit re-laid
the text half a line to the side.

Resolve run -> named sheet -> paragraph's default character style ->
Normal, ignoring missing or out-of-range references. On export, runs
whose sheet reference is rewritten also get the attributes they
inherited, so they keep their look whatever they now point to.

* PSD type: place text at its Txt2 frame and keep box lines whose descent overhangs (#1317)

Recent Photoshop keeps part of a type layer's position in its Txt2 frame: a
frame-to-text-space matrix and, for point text, the anchor, with EngineData's
PointBase left at the origin and BoxBounds in the frame's space. Import ignored
them, so box text landed far off the canvas once an edit started (the text
'disappeared') and point text moved up a line and off its alignment anchor.
Apply the frame when its origin lands in the TySh bounds (and not already
without it, so a TySh PhotoCraft rewrote is not moved twice).

Photoshop also keeps a box line whose baseline is inside the box while its
descenders hang below it; layout dropped such lines (and some single-line boxes
rendered nothing).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:28:31 -04:00
FinnNkandClaude Opus 5.5 8d49ecf50d feat: Photoshop's tool keys (arrows turn the brush, Shift-click new type, Space+F canvas colour) (#3725)
* feat: Left and Right arrows turn the brush tip

With a painting tool, Photoshop's Left and Right arrow keys turn the brush
tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush
turns the session brush's angle by "degrees", wrapped into -180..180, and
move_mods::arrow_keys gives it the plain and Shift arrows only for the
painting tools, when no Move-tool, transform or selection nudge applies
and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still
nudge the layer.

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

* feat(ui): Shift-click with the Type tool starts new text over existing type

In Photoshop a click on existing type with the Type tool edits it, and a
Shift-click there starts a new type layer instead, so text can be placed
over other text. pointer_down now leaves a Shift-click unconsumed when no
type is being edited, so it starts new text; inside the text being edited,
Shift-click still extends the selection.

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

* feat(ui): Space+F cycles the canvas colour

Photoshop's Space+F steps the pasteboard (canvas) colour through Default,
Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F
steps back, round and round. While the Hand key is held, F does this
instead of Cycle Screen Mode; the choice is saved like picking it in the
pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new
tool keys.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:17:43 -04:00
aa4d4859b4 fix(io): preserve indexed BMP color tables (#3802) (#3869)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:13:38 -04:00
3f72158f0a fix(psd): write kept 8B64 blocks as 8BIM in a PSD (#3743)
A PSD re-saved from a PSB kept the source's global FMsk and cinf blocks with
their 8B64 signature, and Photoshop 27.11 refuses such a file as "not
compatible with this version". 8B64 exists only in PSB, so a kept block is
written as 8BIM when the output is a PSD; a PSB keeps 8B64. Measured by
bisecting the 3 refused corpus re-exports in Photoshop: each opened once the
8B64 blocks were gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 09:05:22 -04:00
81f9745033 fix(smart-object): keep pixels when rasterizing smart object layer (#3873)
Resolve an issue where rasterizing a smart object layer resulted in an empty raster surface if the cache was not yet populated. Now refreshes the smart layer snapshot if needed, extracts the rendered surface, converts to the document pixel format, and cleans up PSD placed-layer blocks (SoLd, SoLE, PlLd). Includes a regression test suite covering solid fills, new layers, shape layers, and transformed smart objects.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:03:40 -04:00
cbcdeb2549 Layer styles: Photoshop's measured contour presets; Satin's contour lookup (#3803)
* fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end

The built-in contour presets were hand-drawn guesses and none matched
Photoshop. They are now Photoshop 27.11's own curves, measured by running
a Satin with each preset against a Linear one (38 levels per curve,
thinned to within 1 %, straight segments).

The measurement also showed how Satin applies a contour: |c(1 - v) - c(1)|
(Cone and Cone - Inverted Satins are identical; a Gaussian Satin is 0.36
at 0.5 while the Gaussian on a glow is 0.64). New effects::satin_lut, used
by the CPU and GPU satin paths.

Probe results vs Photoshop (max level error): Satin with every preset
<= 3 (Cove - Shallow 9), was up to 128; Outer Glow Gaussian 38 -> 2,
Ring - Double 121 -> 15, Cone 48-52 -> 4-7; bevel Contour Cone at 100 %
range 27 -> 12. Corpus oracles unchanged (their stored curves are
symmetric). Gloss Contour is still off: that is the bevel's gloss model,
not the presets.

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

* Layer style UI test: the Contour Editor on the measured Cone

The measured Cone preset has 18 points, all inner ones corners, so the
editor test moves point 1 between its neighbours and checks the corner
flags by position instead of the old three-point shape.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 08:52:57 -04:00
bbe77354e0 Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, measured (#3815)
* Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's

Measured as a black box on Photoshop 25.4's Camera Raw Filter.

Whites and Blacks are global curves of the grey level: the measured rows
per slider value, Whites first, after Shadows and Highlights. The
clipping directions adapt to the image's black and white points
(Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2).
On ten public-domain photos the mean error against Photoshop drops from
4.2-36.8 to 0.9-8.9 levels of 255.

Shadows and Highlights are a local Laplacian filter (Paris et al. 2011,
fast form of Aubry et al. 2014) on the log luminance, the method Adobe
credits for process version 2012. Shadows raises pixels darker than
their surroundings by an amount set by their own and their
surroundings' tone, so big edges keep no halo, and keeps the 99th
percentile; Highlights pulls pixels brighter than their surroundings
toward them and keeps the 1st. The strengths are fitted to the photos
(mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator
was not recovered.

The tone stages decode light with the document's curve: filter.cameraRaw
stores encodingGamma (1.8 for ProPhoto, where raws open).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Camera Raw Shadows/Highlights: no contour lines on smooth gradients

Twelve reference values over a raw's whole range left more than a stop
between them, and the remapping changes within half a stop: coefficients
interpolated across that gap drew contour lines in out-of-focus areas
(Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop
apart over the image's tones (the deepest 0.1 % share the lowest), on a
copy at most 1600 px across whose change comes back to full size by a
guided upsampling, so a 16-megapixel image takes about 2 s per slider and
the preview matches the result.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop

Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at
+-100 and +50, exposure +-1) against our own linear development of the same files.

- Shadows and Highlights of the open-time dialog now act while developing, in linear light before
  the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2
  gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy
  1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the
  linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the
  preview undoes the camera curve to show the same (photocraft_raw::retone).
- The D4 profile copies Camera Raw's default rendering: look matrix folded into the
  ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of
  the luminance, Sensor::black_point).

Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference
0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights
-100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: the open-time Camera Raw re-develop covers Shadows and Highlights

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does

The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs:
with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38
stop lighter. Two causes:

- The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting
  first let the lift wash out the black point's deep-shadow contrast).
- The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges
  but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near
  strong edges the effect still matches Photoshop as well as elsewhere.

Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation
Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop

Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor
keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act
before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99).

- Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on
  the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus
  a tone term; carried to full size with the Shadows / Highlights change.
- Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response
  (small detail raised more than strong edges, flat areas unchanged), guided-upsampled.
- Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3,
  airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100;
  −100 a uniform veil. The black point stays the default rendering's.

Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect
correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85;
Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows
differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886).

The open-time Camera Raw dialog re-develops for them and its preview shows them (retone).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 08:52:45 -04:00
6c7328b709 Workspace: wire Auto-Collapse Iconic Panels (#204) (#3880)
* ui: a panel expanded from its collapsed strip auto-collapses on an outside click

* ui: test the Auto-Collapse Iconic Panels peek open and outside close

* engine: show Auto-Collapse Iconic Panels in Preferences, it is wired

* docs: scorecard, settings that do nothing 30 -> 29 of 154

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:52:32 -04:00
Diego Chagas de22bca760 Remove tool: Create a new layer, and Remove after each stroke off (#1750) (#3808)
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo
step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above
the target, only where the hole is, leaving the target as it was (with Sample All Layers it
fills from the composite). The options bar shows Photoshop's three options; with Remove after
each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and
Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
2026-10-12 08:51:56 -04:00
Jakub 286ff73533 perf: edits, undo and redo recomposite only where added or removed layers draw (#3847)
Session::edit reported damage only for edits that changed no pixels (same_pixels) and left
every other edit to recomposite the whole document unless the command reported its own
damage. Duplicate and paste do (#2883, #2901), but Fill, Invert, filters, Delete Layer and
the undo/redo of a duplicate, a paste or a delete all recomposited everything.

- Session::edit now reports step_damage(before, after), the same bound undo and redo use.
- step_damage bounds layers only one side has in both directions, so a removed layer counts
  where it drew like an added one; the bounded_additions flag and insert_damage go, and
  added_bounds becomes one_sided_bounds. A layer that is (or was) the clipping base of
  clipped layers above it, an adjustment, a video layer or one excluding channels still
  recomposites everything (#3090).
- The subject-mask cache is dropped only when the step can have changed pixels.

perf_scenarios on the layered 20 MP document, edits on a 1 MP layer (p50, RTX 3090, Vulkan):
delete layer 9.1 -> 1.4 ms, undo a duplicate 9.3 -> 1.6 ms, fill a 500 px square
17.4 -> 5.5 ms, Invert 16.7 -> 9.2 ms. Duplicate and paste are unchanged (already bounded).

Tests: duplicate_paste_and_delete_report_where_the_layer_draws checks that each step, its
undo and its redo report the same bounded damage and that no pixel outside it changes.
new_layers_that_change_clipping_recomposite_everything (renamed) now expects bounded damage
for a painted or empty new layer and None for a new clipping base or an adjustment; deleting
and restoring a layer reports its bounds.
2026-10-12 08:47:05 -04:00
Jakub 05c4cd685e perf: the Navigator redraws only the area an edit changed (#3845)
With the Navigator open, every edit re-rendered the whole document into a new thumbnail on the
UI thread: a full-resolution CPU composite, 303 ms after an opacity change and 486 ms after a
brush stroke on a 20 MP layered document.

The navigator cache now records the document revision of its image. When the document is
exactly one edit ahead, with the same preview key, a bounded last_damage and the same thumbnail
size, only the damage (grown by the layers' effect reach) is rendered with
render_reduced_damage and written into the texture with set_partial; an edit with empty damage
(a rename, a selection, a new empty layer) costs nothing. Anything else renders the whole
thumbnail as before. compose gains thumbnail_size, which thumbnail_buffer now uses, so both
paths agree on the size.

On the 20 MP document: 303 -> 36 ms after an opacity change, 486 -> 171 ms after a stroke
(RTX 3090 machine; the navigator renders on the CPU).

Test: navigator_damage_updates_match_a_full_thumbnail patches a thumbnail after a translate,
an opacity change, a stroke, a blend mode change and a new layer on a document with a drop
shadow, and compares it with a full thumbnail; it fails without the effect-reach inflation.
2026-10-12 08:46:53 -04:00
Jakub 3e428e7302 perf(gpu): a duplicated layer's texture starts as a GPU copy of its source's (#3848)
A duplicate shares its source's tiles (Arc), but its resident texture was new, so the
compositor converted and uploaded every tile again: duplicating a 20 MP photo layer uploaded
330 tiles. When a new resident's surface shares tiles with another resident of the same role,
region, texel kind, format and default pixel, its texture now starts as a copy of that one
(Stats.tiles_shared counts the tiles taken) and the usual per-tile pass fixes up whatever
differs. Layer textures gain COPY_SRC.

The copy is recorded in the frame's encoder, after whatever brought the donor up to date this
frame. Queued writes land before the encoder runs, so both textures are marked `ordered` and
their writes go through the encoder from then on; otherwise a write to either in the same
refresh would be overwritten by the copy or leak into it. A resident whose region only moved
still keeps its own texels (keep_overlap) first.

Duplicating a 20 MP photo layer in perf_scenarios: 25.1 -> 6.3 ms, 330 tile uploads -> 0
(RTX 3090, Vulkan). Paste is unchanged: the pasted layer has new tiles.

Test: duplicates_copy_their_source_texture covers a plain duplicate (no uploads), a
duplicate and its source each painted with one tile in the same refresh, and a duplicate
missing a tile, all against the CPU composite. Without the `ordered` routing the second case
is off by 167.6/255.
2026-10-12 08:46:28 -04:00
Jakub 9ff6c57e03 GPU composite: submit every 1000 copies and render passes, not only between page cells (#3844)
The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack
recorded every pass into a single command buffer: 700 full-size layers make 1403 render
passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command
buffers per queue, so a few thousand passes in one cell can lose the device.

run_cell now counts copies and render passes and, when a refresh may submit in between,
submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the
page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the
queue keeps the order, so a chunk continues in the next command buffer. A device lost in
such a submit ends the refresh with an error instead of recording more work.

Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise.

Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs
flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes
without a flush.
2026-10-12 08:46:16 -04:00
Scott C Hughes 3cc0d48410 fix(engine): Flatten All Layer Effects keeps layer and vector masks (#3804) (#3835)
Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op.

Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored
2026-10-12 08:46:04 -04:00
MrCloudy2andClaude Opus 5.5 04d2874e38 fix(image): 32 Bits/Channel holds linear light, as in Photoshop (#3792)
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).

With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).

Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:45:48 -04:00
Desayuno64 8af15cf72a Shift Edge moves by a fraction of a pixel so the slider does not snap. (#3800)
100% is still max(radius, 4) pixels. Amounts between whole pixels blend the two neighbouring morphs.
2026-10-12 08:45:25 -04:00
MrCloudy2andClaude Opus 5.5 7890b277a0 fix(adjust): absolute Selective Color scales by the room the ink has (#3793)
Photoshop's absolute mode scales each range's ink change by how much the
ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by
min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so
Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in
Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1
level now (8 of 72 within 3 before). CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:45:09 -04:00
Reynold Hu dcc9f86aa5 Workspace: wire Enable Narrow Options Bar (#204) (#3870)
* engine: expose workspace.enableNarrowOptionsBar through a Workspace helper

* ui: the options bar honors the narrow options bar preference

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:43:48 -04:00
fc02c91ffb io: read version 3 Photo Filter colour as XYZ, not Lab (#3790)
Photoshop CS2 writes `phfl` version 3 with "4 bytes each for XYZ color".
The parser read those values as Lab x100, so real files (L = 241.91)
fell back to Unsupported. Read them as D50 XYZ with 1.0 = 32768 (the
ICC 16-bit PCSXYZ encoding; the scale is inferred from a real file, the
spec does not give it). Writing stays version 2 RGB.

Fixes #3777

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:43:41 -04:00
Reynold Hu b0554b8178 Type: wire missingGlyphProtection (script fallback toggle) (#3863)
* text: missing glyph protection can turn the script fallback stack off (#1909)

* engine: apply type.missingGlyphProtection to the live text engine

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:34:22 -04:00
25c0ec78d5 Keep CMYK ink values when flattening and merging (#3855)
Flatten Image, Merge Visible and Merge Down now composite a CMYK stack of
plain Normal raster layers in its ink values, so a lone Background flattens
bit-exact and 16-bit depth is kept. Stacks that still need the RGB
compositor separate back through the document's own CMYK profile instead of
the built-in one, and Merge Down's scratch document keeps the profile.

Fixes #3778

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:33:50 -04:00
David MannandClaude Opus 5.5 6231325aea Brushes: drag a brush folder into another folder (#2014) (#3851)
Photoshop's Brushes panel nests folders by dragging one onto another; ours
could only reorder a folder among its siblings.

- brush.presets.moveGroup takes `into: {group?, folder?}`: the group or
  nested folder (with everything in it, presets in order) moves inside that
  folder of that group (`folder` [] = directly in the group), or with no
  `group` out to the top level as a group of its own. `before` names a
  sibling folder (or top-level group) there to land before. Moving a folder
  into itself or one of its own folders, a name clash at the destination,
  a result deeper than MAX_FOLDER_DEPTH, the ungrouped presets, `index`
  with `into`, and bad shapes are errors that leave the library unchanged.
  The preset store follows the move (a group moved into another one loses
  its group file; a folder moved out gets one).
- Brushes panel and Brush Preset picker: a group or folder header dropped
  on another header goes inside it; on the header's upper third it lands
  before that folder, reordering siblings or moving a nested folder next to
  it (out to the top level before a group). Every drop is one moveGroup
  command (`folder_drop` maps the drop).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 08:33:28 -04:00
bacc886a6d Preserve signed HDR colors during surface transforms (#3857)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 08:32:45 -04:00
6037ade5ed Fix straight-path anchor insertion at the hit position (#3856)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 08:32:15 -04:00
Scott C Hughes 2aa1f8d2c6 feat(engine): add history.undo and history.redo aliases (#3675) (#3786)
A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z.

Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity.
2026-10-12 07:49:26 -04:00
MrCloudy2andClaude Opus 5.5 c5da1a0d5f Hue/Saturation: Photoshop's saturation curve, lightness order and Colorize lightness (#3781)
* fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop

Measured in Photoshop 27.11:
- Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft
  multiplied by (1 + s), up to 26 levels off.
- Master Lightness applies first; hue and saturation then work on the
  result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue
  +40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %.
- Colorize builds the colour at the lightened lightness, so black with
  Lightness +30 is a 30 % lightness colour, not grey (was 78 off).
- A colour range's positive Lightness lightens towards the pixel's
  brightest channel, not towards white (Blues +50 was 71 off).

All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the
Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb
120 -> 3). CPU and GPU paths.

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

* fix(adjust): a range's negative Lightness darkens towards the darkest channel

Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204)
at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel,
not towards black. Mirrors the positive rule (towards the brightest).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:49:16 -04:00
MrCloudy2andClaude Opus 5.5 836fd52089 fix(adjust): Black & White tints like the Color blend mode, as Photoshop does (#3780)
Photoshop's Black & White tint keeps the grey's luminosity and takes the
tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the
same as the Color blend mode. PhotoCraft mixed half the grey with twice
the grey times the tint, up to 141 levels off. Measured in Photoshop
27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all
within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16
black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %.
CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:49:05 -04:00
UnemarsandClaude Opus 5.5 e35671ffa9 Shadows/Highlights: Photoshop's, measured (#3765)
Image › Adjustments › Shadows/Highlights follows Photoshop 25.4, measured as a
black box: per-channel screen curves (1 - (1 - v)^q blended over q = 1, 2, 4,
8), the Tone mask as a Beta distribution function of an 8-bit neighbourhood
brightness, an edge-aware (guided bilateral) neighbourhood, the luma of the
result with the colour differences scaled by Color, and the Midtone S-curve.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:55 -04:00
DennisandClaude Opus 5.5 e4e3f00f48 Camera raw colour fit: read a Sony Adobe RGB preview as Adobe RGB, not sRGB (#3760)
The per-file colour fit (`fit_preview_look`) converted the embedded JPEG to sRGB only when the JPEG
carried an ICC profile, and otherwise read it as sRGB. A Sony body set to Adobe RGB writes no profile
into its previews; it states the colour space only in the raw's EXIF (ColorSpace Uncalibrated with
InteropIndex "R03", the DCF option file for Adobe RGB). The fit then targeted a desaturated,
hue-shifted reference, and the developed raw came out that way.

`photocraft_raw::embedded_preview_color_space` reads that EXIF (sRGB, Adobe RGB or unstated; never for a
DNG, whose previews are the converter's), and `preview_profile` uses it when the preview has no ICC
profile of its own. A preview's own profile still wins. LightCraft reads the same tags for the same
reason (storytold/lightcraft#778).

Measured on 19 private Sony ILCE-1M2 ARWs, all shot in Adobe RGB, against the camera's JPEG read as
Adobe RGB (median CIE76 ΔE at 240 px): full-frame 400 mm shots against blue sky 5.6-7.0 → 1.5-2.4,
their mean a* offset +4.3…+4.9 → +0.7…+1.1; every file improved, mean of the medians 4.9 → 2.2.

Tests: `exif_colour_space_of_the_previews` (sRGB, Adobe RGB; R98, a missing interop IFD, other values,
a DNG and junk are unstated) and `a_preview_without_a_profile_takes_the_raws_exif_colour_space` (fails
without the EXIF fallback; the preview's own ICC profile wins). The hostile-input harness calls the new
function.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:45 -04:00
David MannandClaude Opus 5.5 81bff2aaa4 Brushes: Photoshop's texture Height/Subtract modes, Brightness and Contrast (#3340) (#3749)
Imported brushes whose Texture uses Height or Linear Height at a low depth
(common in Photoshop brush packs) painted nothing: the modes were modelled
backwards (paint only on the brightest texels, more of it with more depth).
Measured against Photoshop with single dabs over a 0..255 ramp pattern:

- Height: paint reaches 12 x depth x coverage into the pattern and fills
  what lies below it (dark valleys first; a brighter pattern takes paint
  away, which is why the reporter had to flip Brightness's sign).
  Linear Height grades the shallow end by the pattern instead.
- Subtract takes depth x pattern away (white removes the most paint);
  Color Dodge/Burn scale the pattern toward their neutral with depth.
  These now live in texture_combine; the Dual Brush keeps mask_combine.
- Brightness shifts the pattern by value/255 (not value/150); positive
  Contrast steepens by 1/(1-c/100) after the shift, negative flattens by
  1+c/100 before it. Texture::level is shared by the renderer and the
  Brush Settings swatch.
- ABR import mapped textureContrast with /50 for both signs; positive
  values are now /100, matching the model and the Brush Settings field.

Built-in Subtract presets get Invert so they look as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:35 -04:00
David MannandClaude Opus 5.5 b071929ecb Type: editing text keeps its ligatures and OpenType alternates (#3617) (#3747)
Photoshop keeps Stylistic Sets, Contextual Alternates, Swash, Fractions, Ordinals, Titling and
Stylistic Alternates and Ornaments only in a PSD's Txt2, not in EngineData. Import now reads them
into the runs' font features, so the layout drawn while the type is edited keeps Photoshop's
glyphs instead of falling back to the default ones.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:24 -04:00
MrCloudy2andClaude Opus 5.5 56c5fd5797 fix(effects): Satin at distance 0 draws as 1 px, as Photoshop does (#3744)
Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of
2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured
on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12
cases already matched within 1 level. Applied to the CPU and GPU paths and
to the effect reach.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:14 -04:00
224cebbd8a fix(automation): warn when text falls back from a missing font (#3391) (#3630)
* fix(automation): warn when text falls back from a missing font (#3391)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:03 -04:00
JGFandClaude Opus 5.5 edc1b1e774 View: remember Extras, Show and Snap To across restarts (#3762) (#3787)
* View: remember Extras, Show and Snap To across restarts (#3762)

View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and
the View › Snap To targets reset to their defaults on every launch. They
are now saved in Preferences (`viewExtras`) as they change and restored at
startup, as Photoshop keeps them between sessions.

Only items that differ from their defaults are stored, so a default
changed in a later release still reaches users who never touched it. A
hand-edited file can't break them: unknown keys and non-boolean values
keep their defaults.

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

* View: rustfmt the view-extras restart test (#3762)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:47:33 -04:00
53d814f643 feat: Photoshop's layer and selection keys (Ctrl+, toggle, Ctrl+\, /, Ctrl+Alt+2, Ctrl+Alt+Backspace, Tab rename, Alt-click thumbnail) (#3710)
* feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock

Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once
they are all hidden, shows them again: its Layer menu item then reads
Show Layers. layer.hideLayers now does the same, so the key toggles: it
shows them only when every targeted layer is hidden.

The / key toggles Lock Transparent Pixels, as the Layers panel's first
lock button does (layer.toggleLockTransparency). With several layers
selected, any unlocked one means lock them all; the Background, which
can't take this lock, is left out. Each press is one undoable step.

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

* feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity

Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking
its thumbnail does, beside Ctrl+2 for the composite
(channel.target.layerMask; greyed when the layer has no mask). Its
Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection,
the Ctrl-click on the composite channel (select.loadLuminosity).

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

* feat(engine): Ctrl+Alt+Backspace fills from history

Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or
the layer, from the History Brush's source state. edit.fillHistory is
edit.fill with contents "history": the oldest state still held, unless
"state" names another. It joins the other fill keys, so it honours the
Channels panel target and appears with them in Keyboard Shortcuts.

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

* feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer

While a layer name is being edited, Tab commits it and starts renaming the
layer below, and Shift+Tab the layer above, as in Photoshop; the
Background, which isn't renamed in place, ends it. Alt-clicking a layer's
thumbnail selects the layer and fits its content on screen (View › Fit
Layer(s) on Screen).

Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace
through the shortcut dispatcher. docs/ui-parity.md lists the new keys.

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

* fix(ui): an explicit channel target drives the Layers panel's mask target

After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back
to the composite, but the Layers panel kept the mask targeted (its own
flag, which only ever followed the engine towards the mask), so painting
still went to the mask. The same happened after clicking the mask
thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets
the panel's mask and vector-mask targets from the engine's target.

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

* Use Document::is_background after #4036 removed extra_cmds::is_background

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 07:32:23 -04:00
3dd5fc1cdc automation: say why a command is disabled in engine.commands (#3740)
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.

Fixes #3664

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:57 -04:00
Valentin KleinandClaude Opus 5.5 6751a46003 Codecs: integer fast paths for Image::convert (#3717) (#3729)
U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:45 -04:00
89312de597 Answer a repeat auth with "already authenticated" (#3723)
A second `auth` frame on an authenticated TCP connection fell through to
the method dispatcher and came back as an unknown method, in both the
headless serve server and the desktop control server. A shared
`security::repeat_auth_reply` now answers it with `already authenticated`
(keeping the request id) and the connection keeps serving.

Fixes #3668

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:32 -04:00
Nicolas Falesy 0c2ca488ae fix(text): scale faux bold dilation with glyph scaling (#3705) (#3719)
When drawing faux-bold glyphs, the offset dilation was composed as
`Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o`
first (`self ∘ o`), the translation offset was added after the glyph's
scaling transform, leaving faux bold dilation unscaled by horizontal
and vertical font scaling.

Applying the glyph transform to the offset transform (`glyph.mul(&offset)`)
ensures dilation is performed in font-local coordinates and scaled
proportionally with anisotropic glyph scaling, matching Photoshop.
2026-10-12 07:13:06 -04:00
e54adc250c feat(codecs): decode legacy PCX images and palettes (#3692) (#3701)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:55 -04:00
Streetblock 38236f90c2 perf(compose): allow empty vector masks in preview proxies (#3716) 2026-10-12 07:12:47 -04:00
Streetblock e156a9ed85 perf(compose): select Normal RGB kernel once per buffer (#3715) 2026-10-12 07:12:34 -04:00
Valentin KleinandClaude Opus 5.5 476f983876 Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:13 -04:00
Valentin KleinandClaude Opus 5.5 7893a6e04d PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:00 -04:00
Valentin KleinandClaude Opus 5.5 21f1012ce2 Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706)
A tiny file declaring a huge image reserved the full output buffer before any
data was checked. Add Limits::check_plausible (compression-ratio guard) and a
fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default
max_alloc to 1 GiB.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:11:41 -04:00
Brandon ThomasandClaude Opus 5.5 b564f2ca9c fix(layers): identify the Background by structure, never by its English name (#4036)
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:04:33 -04:00
Brandon ThomasandClaude Opus 5.5 6547ca0b84 fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031)
- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
  spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
  EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
  they match the channels), except the ones that only describe layers, plus the document-level
  tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
  layered writer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:02:10 -04:00
4705fc5980 feat(io): open Krita .kra composites as editable raster documents (#3692) (#3700)
* feat(io): open Krita .kra composites as editable raster documents (#3692)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:01:20 -04:00
0dbc288667 fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias (#3672) (#3678)
* fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias

warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and
Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification
filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size
in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render
is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly
this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent.

Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices.
Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its
source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8).
Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or
enlarge the source still take the exact single sample, so identity and unscaled warps are
bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead
of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh
warp's scale varies across the surface: one reduction factor over-blurs the parts that stay
large or under-filters the parts that shrink most.

Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop
exports of real mockups (RMSE of the placed design): 1.78% -> 0.81%
(White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged.

Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at
1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid
black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period.

Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7):
  1/3 size: 55 ms -> 87 ms
  1/1 size: 240 ms -> 239 ms (no subsampling: identical path)

Co-authored-by: Claude Opus 5.5 <[email protected]>

* Use as_chunks for the opaque-pixel scan (clippy 1.99)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 06:49:25 -04:00
e4298f554b Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) (#3696)
* Free Transform: layer styles render with the transformed layer (#3653), work in progress

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

* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)

The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).

A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.

The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.

Still plain: the selected part of a styled layer, and Warp mode.

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

* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)

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

* Format the type warp texel read

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

* Free Transform: Content-Aware Scale's preview has no styled composite

Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge builds.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 06:37:15 -04:00
304720b1a0 Gradient Map: edit color stops on the ramp (#3982)
* Gradient Map: edit color stops on the ramp (#2958)

* Gradient Map: keep stop removal scoped to the editor

* Gradient Map: fit selected stop controls in narrow Properties panels

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:30:14 -04:00
Scott MacdonaldandClaude Opus 5.5 590bec7998 feat(io): import Photoshop Displace smart filters with their embedded map (#3680)
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.

Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).

The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.

Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:55 -04:00
Scott MacdonaldandClaude Opus 5.5 5ed758763d PSD: smart object Trnf frames the custom warp's control points, not the source rect (#3677)
A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp
mesh) can have control points outside its source rect 0..w x 0..h. Photoshop
maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft
mapped the source rect onto it, so a mesh that spills past the source counted
the overhang twice and the re-rendered smart object came out stretched.

Measured against Photoshop's own export of a real shirt mockup (4200 x 4800
source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch,
the tile offset growing from 0 to -16 px top to bottom; after, alignment is
exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of
the evaluated warp outline (rather than the control points) fixed that file
but put another at 5.4% RMSE, so it is the control-point box.

- blocks::placed_frame: the shared rule. The control-point box of a
  single-patch custom warp, else the source rect; a degenerate or non-finite
  box falls back to the source rect.
- blocks::parse_smart (and so smart_map::parse_sold): the affine maps that
  frame onto Trnf.
- smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of
  the same frame, so a spilled mesh round-trips and quad_moved keeps the
  imported Trnf as stored. Distort / Perspective placements keep the source
  rect, matching parse_smart_perspective.

Refs #579.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:38 -04:00
Scott MacdonaldandClaude Opus 5.5 2cb396d154 fix(engine): Displace smart filter ignores its mapPath displacement map (#3676)
A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.

apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.

Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:33 -04:00
Valentin KleinandClaude Opus 5.5 64a02107ba PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:18 -04:00
Valentin KleinandClaude Opus 5.5 16341720c0 PSD: bound the zero padding of undersized pattern channels (#3652) (#3686)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:14 -04:00
Valentin KleinandClaude Opus 5.5 7c77143b97 PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:09 -04:00
Valentin KleinandClaude Opus 5.5 04396dd85f PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:27:57 -04:00
Brandon ThomasandClaude Opus 5.5 27f56eb762 feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038)
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:

- Invert and Posterize act on L, a and b alike (normalised L/100,
  (a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
  a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).

Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:20:44 -04:00
zenjabbaandzenjabba ce16e100b0 Expose WebP and optional Batch export quality (#3956)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 06:20:16 -04:00
McClabe be877c27d0 fix(ui): keep filter jobs polling on idle Linux (#3961) 2026-10-12 06:20:00 -04:00
Nigel Ihza Firdausy 6ca7e60cb0 fix(ui): make checkbox labels nonselectable with pointer cursors (#4002) 2026-10-12 06:19:44 -04:00
Nigel Ihza Firdausy 73520f8681 fix(ui): Open layer color choices from the visibility icon (#4001)
* fix(ui): offer label colors from the layer eye icon

* fix(ui): offer label colors from the layer eye icon

* test(ui): cover layer eye color context menu

* style(ui): keep extracted color menu indentation consistent
2026-10-12 06:19:28 -04:00
MarekSpodBiedry db509dc4d9 fix(ui): show a paint-bucket cursor for the Paint Bucket tool (#3968) (#3989) 2026-10-12 06:19:13 -04:00
Vikram Bhaskaran ad3417dc05 Dismiss Home when the document count changes. (#3984)
Chrome stored the document count when Home opened so File New could leave the overlay. Closing a tab that restored that count re-opened Home and hid remaining tabs (#3981). Clear the stored count as soon as it no longer matches, then keep Home dismissed until the user opens it again.
2026-10-12 06:18:58 -04:00
Brandon ThomasandClaude Opus 5.5 0d6e658e73 PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034)
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen

Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.

- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
  (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
  command made it one; a vector mask (vmsk/vsms with its vogk) stays.

Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.

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

* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:11:33 -04:00
Brandon ThomasandClaude Opus 5.5 6f1c4481bf fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info) (#4041)
- TySh: the four values after the warp descriptor are big-endian f32, not
  i32. Written as Photoshop writes them: zeros for unwarped type, the
  text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
  text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
  read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
  3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
  no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
  no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
  write.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:06:46 -04:00
5bc66094f8 fix(ui): allow selecting shapes and editing color with path selection (#3915) (#3932)
* fix(ui): allow selecting shapes and editing color with path selection (#3915)

- In vector_ui.rs, implement pick_shape_at to hit-test visible shape layers by path, surface pixels, or live shape geometry.
- In path_selection_finish and canvas ToolEvent::Down, select the clicked shape layer under the pointer when using Path Selection.
- In direct_select.rs, include visible shape layers in candidate hit tests and sync layer.select on press.
- In options_bar, display Fill swatch/checkbox, Stroke swatch/width/style, and corner radius when a shape is selected with Path Selection or Direct Selection, and dispatch shape.edit.
- Add tests in path_selection_tests.rs and vector_ui.rs verifying shape selection on click and options bar editing.

Fixes storytold/photocraft#3915

* Collapse nested ifs for clippy (collapsible_if)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:05:15 -04:00
Scott C Hughes 24bd7533fc feat(ui): the generic filter dialogs get a Photoshop-style preview window (#3769) (#3877)
Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls.
2026-10-12 06:05:02 -04:00
Fabian GhenofandClaude Opus 5.5 83f4452bf2 Layer › Export As exports every selected layer or artboard (#3629) (#3909)
With several layers selected, Export As was greyed: its dialog exported only
the active layer. Someone with 20+ artboards (ads, Steam store capsules) had
to export them one at a time.

Export As now opens the same dialog for the selection ("Export As: N
layers", the preview showing the first) and, on Export, asks for one
destination and writes one file per layer with the dialog's format, quality,
transparency and scale, each at its own size. Files are named as Quick
Export as PNG names a selection's: the chosen path, then numbered siblings
named after each layer, now in the chosen format. The layers are captured
when Export is pressed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:04:35 -04:00
Scott C Hughes 19e51cf0ad refactor(ui): unify plain text inputs on one text_field widget (#3789) (#3896)
Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm
2026-10-12 06:04:23 -04:00
coolvitto c0fa2269d2 Update Japanese translations (#3892)
Improved readability and several translation corrections.
2026-10-12 05:57:45 -04:00
Brandon ThomasandClaude Opus 5.5 32552a61a1 fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047)
Liquify dabs were spaced a fifth of the brush radius apart and restarted
at every pointer event, so a fast mouse (few far-apart events) and a slow
one (many close ones) warped the same path differently, and a slow drag
piled up one dab per event. Dabs now fall every twentieth of the radius
by distance travelled along the whole stroke, carried from one segment
to the next, as in Photoshop: any sampling of a path gives the same
field. The stationary tools' per-dab strength is scaled to the new
spacing so their effect per distance travelled is unchanged.

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

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

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

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

Refs #2399

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: opt FredApps into public profile credit

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

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

Replaces the scaffold test with one that fails on main.

---------

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

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

Fixes #3713

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

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

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

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

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

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

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

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

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

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

Checked in Photoshop. A click outside still commits.

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

---------

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

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

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

Fixes #2946.

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

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

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

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

Test only; no production change.

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

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

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

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

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

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

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

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

No files matched your search

+8
View File
@@ -257,6 +257,14 @@ jobs:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: psd, name: grd }
- { crate: psd, name: ase }
- { crate: psd, name: aco }
- { crate: psd, name: patterns }
- { crate: psd, name: filter_effects }
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
+6
View File
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+3
View File
@@ -19,6 +19,9 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
Generated
+33 -31
View File
@@ -1743,6 +1743,7 @@ dependencies = [
"miniz_oxide 0.8.9",
"num-complex",
"pulp",
"rayon-core",
"smallvec",
"zune-inflate",
]
@@ -2331,9 +2332,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
version = "0.1.1"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
@@ -4182,7 +4183,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"arboard",
"async-io",
@@ -4224,7 +4225,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4234,7 +4235,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4247,7 +4248,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"base64",
"cap-std",
@@ -4269,7 +4270,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4283,7 +4284,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"moxcms",
"rayon",
@@ -4291,7 +4292,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"exr",
"flate2",
@@ -4312,7 +4313,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-cms",
"serde",
@@ -4320,7 +4321,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"criterion",
"photocraft-color",
@@ -4335,7 +4336,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4346,7 +4347,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"blake3",
"exr",
@@ -4380,9 +4381,10 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"blake3",
"crc32fast",
"flate2",
"photocraft-codecs",
"photocraft-color",
@@ -4399,14 +4401,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"half",
"log",
@@ -4423,14 +4425,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"blake3",
"crc32fast",
@@ -4459,14 +4461,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4477,7 +4479,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4492,7 +4494,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4503,7 +4505,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4513,7 +4515,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"flate2",
"image",
@@ -4528,7 +4530,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4540,7 +4542,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4551,7 +4553,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"flate2",
"parley",
@@ -4565,7 +4567,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"base64",
"craft-ui",
@@ -4604,7 +4606,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4615,7 +4617,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"eframe",
"egui",
@@ -7448,7 +7450,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.8.1"
dependencies = [
"serde",
"serde_json",
+19 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.8.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -109,6 +109,24 @@ opt-level = "z"
[profile.wasm-release.package.photocraft-web]
opt-level = "z"
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
[profile.wasm-release.package.photocraft-doc]
opt-level = "z"
[profile.wasm-release.package.photocraft-psd]
opt-level = "z"
[profile.wasm-release.package.photocraft-format]
opt-level = "z"
[profile.wasm-release.package.photocraft-affinity]
opt-level = "z"
[profile.wasm-release.package.serde_json]
opt-level = "z"
[profile.wasm-release.package.usvg]
opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
+11 -3
View File
@@ -240,7 +240,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
@@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -308,9 +312,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
@@ -338,7 +344,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
warn_all(err, &ws);
}
Ok(())
+44
View File
@@ -203,6 +203,50 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
+16 -19
View File
@@ -305,27 +305,22 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
if let Some(error) = self.presets.error() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
});
}
self.app.ui(ui, frame);
@@ -382,6 +377,8 @@ fn services(inbox: Inbox) -> Services {
};
reply.send(answer);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
+47 -1
View File
@@ -9,7 +9,7 @@ use std::time::Instant;
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
@@ -79,6 +79,12 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
}
continue;
}
if let Some(reply) = repeat_auth_reply(&line) {
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
break;
}
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
@@ -202,4 +208,44 @@ mod tests {
handler.join().unwrap();
server.join().unwrap();
}
#[test]
fn repeat_auth_reports_already_authenticated_without_dispatching() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let after: Value = serde_json::from_str(&line).unwrap();
assert_eq!(after["id"], 9);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}
+9
View File
@@ -644,4 +644,13 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}
+11 -3
View File
@@ -20,9 +20,9 @@ mod recovery_tests;
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "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", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
"pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
"abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -43,6 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
@@ -115,6 +116,13 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
+15
View File
@@ -17,6 +17,10 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
[people.Skyfuller1303]
display_name = "Skyfuller"
sync_github_name = true
@@ -104,8 +108,19 @@ sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true
[people.arthurdeka]
sync_github_name = true
+16
View File
@@ -59,6 +59,22 @@ fn main() {
prev = p;
}
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
// stroke, and how far the two results differ.
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let mut fields = Vec::new();
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
for step in [2.0, 60.0] {
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
let t = Instant::now();
let mut f = LiquifyField::new(b, cell);
f.apply_stroke(&drag);
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
fields.push(f);
}
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
st.tool = LiquifyTool::Bloat;
let t = Instant::now();
let mut d = Rect::EMPTY;
+3 -2
View File
@@ -992,8 +992,9 @@ mod tests {
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
for c in 0..4 {
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
for c in 0..3 {
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
}
}
let cancel = || true;
+1 -1
View File
@@ -169,7 +169,7 @@ fn apply_inner(
return None;
}
if let Some(selection) = selection {
crate::mix_selection(&mut data, output, selection, &src);
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
}
if ctl.cancelled() {
return None;
+2 -2
View File
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
for mask in [None, Some(&selection)] {
let mut expected_data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut expected_data, rect, mask, &src);
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
}
let mut expected = source.clone();
expected.write_region(rect, &expected_data);
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
let mut expected = source.clone();
let mut data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut data, area, mask, &src);
crate::mix_selection(&mut data, area, mask, &src, true);
}
expected.write_region(area, &data);
let expected = Image::read(&expected, check);
+1 -1
View File
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
if let Some(selection) = selection {
crate::mix_selection(&mut data, area, selection, &src);
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
}
let mut result = surface.clone();
result.write_region(area, &data);
+87 -24
View File
@@ -9,9 +9,10 @@
//! with the guided filter); negative amounts add haze toward the atmospheric light.
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
//! weighted) and Texture (small radius) add band-pass local contrast.
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
@@ -54,6 +55,38 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
mod local_tone;
mod whites_blacks;
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Encoding(f32);
impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
fn srgb_encode(v: f32) -> f32 {
linear_to_srgb(v)
}
fn srgb_decode(v: f32) -> f32 {
srgb_to_linear(v)
}
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
/// One colour grading wheel.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -136,6 +169,9 @@ pub struct CameraRaw {
pub seed: u32,
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
pub pixel_scale: f32,
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
pub encoding_gamma: f32,
}
impl Default for CameraRaw {
@@ -191,6 +227,7 @@ impl Default for CameraRaw {
vignette_style: "highlightPriority".into(),
seed: 0,
pixel_scale: 1.0,
encoding_gamma: 0.0,
}
}
}
@@ -491,7 +528,11 @@ impl CameraRaw {
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
}
fn tone_neutral(&self) -> bool {
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
}
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
fn local_tone_neutral(&self) -> bool {
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
}
fn hsl_neutral(&self) -> bool {
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
@@ -561,16 +602,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if p.dehaze != 0.0 {
dehaze(px, w, h, p.dehaze / 100.0);
}
// 3. Tone on luminance.
if !p.tone_neutral() {
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
if p.highlights != 0.0 || p.shadows != 0.0 {
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
}
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
let r = ((long * 0.015) as usize).max(2);
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
} else {
None
};
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
@@ -580,12 +618,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let i = y * w + x;
let l0 = l[i];
let mut lv = lc[i];
if let Some(b) = &base {
let bv = b[i].clamp(0.0, 1.0);
let hm = smoothstep(0.45, 0.95, bv);
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
}
if let Some(c) = &clar {
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
@@ -593,12 +625,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if let Some(t) = &tex {
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
}
if p.whites != 0.0 {
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
}
if p.blacks != 0.0 {
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
}
if k_con != 0.0 {
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
@@ -611,6 +637,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
});
}
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
if p.whites != 0.0 || p.blacks != 0.0 {
let enc = Encoding::of(p.encoding_gamma);
let (bp, wp) = luminance_points(px, w, h, enc);
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
whites_blacks::apply(px, w, xw, xb, enc);
}
// 4–7: per-pixel colour stages.
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
if need_color {
@@ -732,6 +765,36 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
}
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
/// preview proxy and the full image agree).
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
let f = w.max(h).div_ceil(256).max(1);
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
let mut sum = vec![[0.0f64; 4]; sw * sh];
for y in 0..h {
for x in 0..w {
let q = px[y * w + x];
let s = &mut sum[(y / f) * sw + x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
}
s[3] += 1.0;
}
}
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
if cells.is_empty() {
return (0.0, 1.0);
}
let pct = |mut v: Vec<f32>, q: f32| {
v.sort_by(f32::total_cmp);
v[((v.len() - 1) as f32 * q).round() as usize]
};
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
(linear_to_srgb(bp), linear_to_srgb(wp))
}
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
let (xi, yi) = (x.floor(), y.floor());
let (tx, ty) = (x - xi, y - yi);
+428
View File
@@ -0,0 +1,428 @@
//! Shadows and Highlights as a local Laplacian filter.
//!
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
//!
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
//! are left alone, so fine detail stays.
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
//! brightest content under Shadows and the darkest under Highlights).
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
//! image size.
//!
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
//! 3.7–14.3.
use super::{Encoding, luma};
use crate::photo_util::par_rows;
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
/// ramp's slope varies by under a third, as the remapping itself makes it).
const REF_STEP: f32 = 0.125;
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
/// to full size by a guided upsampling, so a preview and the full image agree.
const WORK_SIDE: usize = 1600;
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
const KNOTS: usize = 5;
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
/// See [`SHADOWS`].
struct Lift {
own: [f32; KNOTS],
around: [f32; KNOTS],
sigma: f32,
down: f32,
}
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
/// stops.
struct Side {
pull: [f32; KNOTS],
push: [f32; KNOTS],
sigma: f32,
}
/// One channel, `w × h`.
struct Plane {
w: usize,
h: usize,
v: Vec<f32>,
}
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
fn mirror(i: isize, n: usize) -> usize {
let n = n as isize;
if n == 1 {
return 0;
}
let p = 2 * (n - 1);
let m = i.rem_euclid(p);
(if m >= n { p - m } else { m }) as usize
}
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
let y = (y2 * step) as isize;
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
}
});
Plane { w: w2, h: h2, v: out }
}
/// The next coarser level.
fn down(p: &Plane) -> Plane {
blur_step(p, 2)
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step(&z, 1)
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
let mut n = 1;
while s > 2 {
s = s.div_ceil(2);
n += 1;
}
n
}
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
let mut g = vec![p];
for _ in 1..n {
let next = down(&g[g.len() - 1]);
g.push(next);
}
g
}
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
let n = depth(u.w, u.h);
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
// lowest reference instead of stretching the spacing.
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
let lo = quantile(&u.v, 0.001).min(hi);
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
return Plane { w: u.w, h: u.h, v: u.v.clone() };
}
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
let step = (hi - lo) / (refs - 1) as f32;
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
}
}
});
}
}
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
for l in (0..n - 1).rev() {
let mut next = up(&o, g[l].w, g[l].h);
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
*a += b;
}
o = next;
}
o
}
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
fn range(u: &Plane) -> (f32, f32) {
let f = u.w.max(u.h).div_ceil(256).max(1);
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut sum = vec![(0.0f64, 0u32); sw * sh];
for y in 0..u.h {
for x in 0..u.w {
let c = &mut sum[(y / f) * sw + x / f];
c.0 += f64::from(u.v[y * u.w + x]);
c.1 += 1;
}
}
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
(quantile(&cells, 0.01), quantile(&cells, 0.99))
}
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
fn excess(d: f32, sigma: f32) -> f32 {
d - sigma * (d / sigma).tanh()
}
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
let i = (x as usize).min(KNOTS - 2);
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
}
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
/// surroundings move toward them (or away, for positive values) by the part of the difference
/// past `sigma`, then the darkest content is put back where it was.
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
let o = llf(u, |x, g| {
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
if below == 0.0 {
return x;
}
let w = below * below * (3.0 - 2.0 * below);
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
});
// Keep the brightest content.
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
return;
}
const FLOOR: f32 = 1.0 / 65536.0;
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
if highlights != 0.0 {
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
}
if shadows != 0.0 {
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
}
// The change in log luminance, at full size.
let change: Vec<f32> = if f == 1 {
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
} else {
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
guided_upsample(&small, &d, &u)
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let gain = change[y * w + x].exp2();
for c in 0..3 {
q[c] = enc.encode(enc.decode(q[c]) * gain);
}
}
});
}
/// `u` averaged over `f × f` blocks.
fn shrink(u: &Plane, f: usize) -> Plane {
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut v = vec![0.0f32; sw * sh];
par_rows(&mut v, sw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let (x0, y0) = (x * f, y * f);
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
let mut s = 0.0f64;
for yy in y0..y1 {
for xx in x0..x1 {
s += f64::from(u.v[yy * u.w + xx]);
}
}
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
}
});
Plane { w: sw, h: sh, v }
}
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
let (sw, sh) = (small.w, small.h);
let r = 2;
let eps = 0.01;
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
let (ma, mb) = (mean(&a), mean(&b));
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
let mut out = vec![0.0f32; full.w * full.h];
par_rows(&mut out, full.w, 1, |y, row| {
let sy = (y as f32 + 0.5) * fy - 0.5;
for (x, o) in row.iter_mut().enumerate() {
let sx = (x as f32 + 0.5) * fx - 0.5;
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
*o = av * full.v[y * full.w + x] + bv;
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
const SRGB: Encoding = Encoding(0.0);
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
#[test]
fn a_flat_image_is_unchanged() {
let mut px = grey(64, 48, |_, _| 0.4);
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
}
#[test]
fn shadows_raise_a_dark_area_without_a_halo() {
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
// moves either): the bright half stays, and no halo forms on either side of the edge.
let (w, h) = (256, 128);
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
apply(&mut px, w, h, 100.0, 0.0, SRGB);
let row = &px[64 * w..65 * w];
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
}
#[test]
fn grey_moves_as_in_photoshop() {
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
let n = 256;
let image = |c: f32| {
grey(n, n, |x, y| {
if x >= n - 26 && y >= n - 26 {
0.97
} else if x >= n - 26 && y < 26 {
0.015
} else {
c
}
})
};
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
let mut px = image(0.5);
apply(&mut px, n, n, s, hl, SRGB);
let got = px[175 * n + 100][0] * 255.0;
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
}
}
#[test]
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
let (w, h) = (192, 192);
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
apply(&mut px, w, h, 0.0, -100.0, SRGB);
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
}
#[test]
fn smooth_gradients_stay_smooth() {
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
// between references too far apart drew contour lines across such ramps (Shadows +100 on
// a D4 raw): the output must rise steadily, without ripples.
let (w, h) = (640, 48);
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
let mut p = px.clone();
apply(&mut p, w, h, s, hl, SRGB);
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
}
px.clear();
}
#[test]
fn the_result_does_not_depend_on_the_image_size() {
let at = |n: usize| {
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
apply(&mut px, n, n, 70.0, -60.0, SRGB);
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
};
let (a, b) = (at(96), at(384));
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
}
}
+418
View File
@@ -0,0 +1,418 @@
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
//!
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
//! histograms went through the filter by script and came back exactly; no Adobe code or data
//! was used.
//!
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
//! 0.15); its curves there are not rows of the full-range family.
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
use super::Encoding;
use crate::photo_util::par_rows;
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
const STEPS: usize = 33;
/// The first row's internal amount; rows are 10 apart.
const WHITES_FROM: f32 = -100.0;
const BLACKS_FROM: f32 = -100.0;
// Measured levels; some happen to lie near π/8 and π/4.
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
#[allow(clippy::approx_constant)]
static WHITES: [[f32; 33]; 38] = [
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
],
[
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
],
[
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
],
[
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
],
[
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
],
[
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
],
[
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
];
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
#[allow(clippy::approx_constant)]
static BLACKS: [[f32; 33]; 21] = [
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
],
[
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
],
[
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
],
[
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
],
[
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
],
[
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
],
[
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
],
[
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
],
[
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
],
[
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
],
[
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
],
[
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
],
[
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
],
[
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
],
[
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
],
[
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
],
];
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
static AW: [[f32; 8]; 9] = [
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
];
static AB: [[f32; 8]; 9] = [
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
];
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
if v >= 1.0 {
return 1.0;
}
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
let i = (r as usize).min(rows.len() - 2);
let t = r - i as f32;
let at = |row: &[f32; STEPS]| {
let p = v.max(0.0) * (STEPS - 1) as f32;
let j = (p as usize).min(STEPS - 2);
let s = p - j as f32;
row[j] + (row[j + 1] - row[j]) * s
};
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
}
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
pub(crate) fn whites(v: f32, x: f32) -> f32 {
curve(&WHITES, WHITES_FROM, x, v)
}
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
curve(&BLACKS, BLACKS_FROM, x, v)
}
/// The least Blacks adaptation on photographs.
const PHOTO_BLACKS: f32 = 1.2;
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
let idx = |grid: &[f32], v: f32| {
let v = v.clamp(grid[0], grid[grid.len() - 1]);
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
};
let (i, s) = idx(&BPS, bp);
let (j, t) = idx(&WPS, wp);
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
row(i) * (1.0 - s) + row(i + 1) * s
}
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
// their black point is 0 (ten public-domain photos: 1.08–1.36).
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
}
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
if w == 0.0 && b == 0.0 {
return;
}
// A grey level's new level, both sRGB-encoded.
let grey = |v: f32| {
let v = if w != 0.0 { whites(v, w) } else { v };
if b != 0.0 { blacks(v, b) } else { v }
};
let per_channel = w > 0.0 || b < 0.0;
par_rows(px, width, 1, |_, row| {
for q in row.iter_mut() {
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_range_rows_match_photoshop() {
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
// The compressing directions keep black and white.
assert_eq!(whites(1.0, -100.0), 1.0);
assert_eq!(blacks(0.0, 100.0), 0.0);
}
#[test]
fn the_clipping_directions_adapt_to_the_image() {
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
assert!((w - 94.5).abs() < 2.0, "{w}");
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
assert!((b + 58.0).abs() < 1.0, "{b}");
// Never below the photographs' 1.2.
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
// The compressing directions never adapt.
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
}
}
+29 -4
View File
@@ -1095,7 +1095,7 @@ fn apply_tiled_with_impl(
return (*t, Vec::new());
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src);
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
@@ -1179,7 +1179,7 @@ fn apply_box_blur(
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
for (t, data) in v.iter_mut() {
if let Some(sel) = selection {
mix_selection(data, *t, sel, &Image::read(surface, *t));
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
}
s.write_region(*t, data);
}
@@ -1202,8 +1202,9 @@ fn apply_box_blur(
}
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
/// coverage (channel 0 of `sel`).
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
let w = t.width() as usize;
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
@@ -1211,6 +1212,27 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
@@ -1229,6 +1251,9 @@ mod tests_ext;
#[cfg(test)]
mod tests_mosaic;
#[cfg(test)]
mod tests_selection_mix;
/// The current upstream sampler through the identical source/selection/tile pipeline.
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
+196 -56
View File
@@ -154,7 +154,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,6 +165,38 @@ pub struct LiquifyField {
pub d: Vec<[f32; 2]>,
/// Freeze mask per node (0 = free, 1 = frozen).
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
/// transient brush state, not part of the result.
impl PartialEq for LiquifyField {
fn eq(&self, o: &Self) -> bool {
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
}
}
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
/// the brush has travelled along the path since that dab.
#[derive(Clone, Copy, Debug, PartialEq)]
struct StrokeCursor {
end: [f64; 2],
last_dab: [f64; 2],
carry: f64,
}
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
(r * 0.05).max(0.5)
}
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
/// was with dabs a fifth of the radius apart.
fn moving_weight(r: f64) -> f64 {
dab_spacing(r) / (r * 0.2).max(0.5)
}
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
@@ -177,7 +209,7 @@ impl LiquifyField {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -220,6 +252,7 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -306,77 +339,120 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
}
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
/// along the whole stroke, carried from one segment to the next, so the result is the same
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
/// would change nothing), which tests compare with the full walk.
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let stationary = s.tool.is_stationary();
let cur = match self.cursor {
Some(c) if c.end == [a[0], a[1]] => c,
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
};
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
if len == 0.0 {
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
self.cursor = Some(cur);
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
}
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
if !len.is_finite() {
self.cursor = None;
return dirty;
}
let r = (s.size / 2.0).max(0.5);
let spacing = (r * 0.2).max(0.5);
let steps = (len / spacing).ceil().max(1.0) as usize;
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
// touching anything, and a segment to a far-off point has trillions of them (#937).
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
for k in k0..=k1 {
let t0 = (k - 1) as f64 / steps as f64;
let t1 = k as f64 / steps as f64;
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let pr = pa + (pb - pa) * t1;
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&self.dab(s, centre, delta, pr));
let spacing = dab_spacing(r);
let weight = if stationary { moving_weight(r) } else { 1.0 };
// Distance along this segment of its first dab, then one every `spacing`.
let first = (spacing - cur.carry).clamp(0.0, spacing);
// Distances are compared with a little slack: a dab that rounding puts a hair past the
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
let slack = spacing * 1e-6;
if first > len + slack {
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
return dirty;
}
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
let (mut k0, mut k1) = (0.0f64, last_k);
if skip {
match self.reachable_span(a, [dx, dy], len, r) {
// A dragging dab is centred one spacing behind its position: pad by that and a step.
Some((d0, d1)) => {
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
}
None => k1 = -1.0,
}
}
if k0 <= k1 {
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
for k in (k0 as u64)..=(k1 as u64) {
let dist = (first + k as f64 * spacing).min(len);
let c = at(dist);
let delta = [c[0] - prev[0], c[1] - prev[1]];
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if stationary { c } else { prev };
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
prev = c;
}
}
let last = (first + last_k * spacing).min(len);
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
dirty
}
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
/// node only when its centre is within `r` plus a node of the node grid, so the window below
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
/// near that span can touch a node; a segment to a far-off point has trillions of others
/// (#937).
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
if self.w == 0 || self.h == 0 {
return None;
}
let pad = r + 2.0 * self.cell;
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
let (mut u0, mut u1) = (0.0f64, 1.0f64);
for ax in 0..2 {
let d = b[ax] - a[ax];
let d = dir[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
u0 = u0.max(v0.min(v1));
u1 = u1.min(v0.max(v1));
}
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
if u0.is_nan() || u1.is_nan() || u0 > u1 {
return None;
}
Some((k0 as usize, k1 as usize))
Some((u0 * len, u1 * len))
}
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
/// document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
let density = (s.density / 100.0).clamp(0.0, 1.0);
if strength <= 0.0 {
@@ -921,21 +997,9 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every step, whether or not its dab can reach the field.
/// The segment walk before #937: every dab, whether or not it can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let r = (s.size / 2.0).max(0.5);
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
for k in 1..=steps {
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
}
dirty
f.walk_segment(s, a, b, false)
}
#[test]
@@ -945,7 +1009,7 @@ mod tests {
// every result bit-identical to the full walk.
use LiquifyTool::*;
let bounds = Rect::new(-7, 3, 89, 67);
let segments: [([f64; 3], [f64; 3]); 9] = [
let segments: [([f64; 3], [f64; 3]); 10] = [
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
@@ -955,6 +1019,7 @@ mod tests {
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
];
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
for size in [6.0, 40.0] {
@@ -973,6 +1038,81 @@ mod tests {
}
}
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
let mut out = vec![path[0].to_vec()];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
// Uneven steps, as a real pointer gives: t grows quadratically.
for k in 1..=parts {
let t = (k * k) as f64 / (parts * parts) as f64;
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
}
}
out
}
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
/// whole stroke: any sampling of a path gives the same field.
#[test]
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
use LiquifyTool::*;
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
for size in [12.0, 40.0] {
let field = |parts: usize| {
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
let mut s = LiquifyStroke::new(tool, size);
s.points = sampled(&path, parts);
// Some displacement first, so Reconstruct and Smooth have something to change.
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
};
let coarse = field(1);
for parts in [3, 17, 60] {
let fine = field(parts);
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
}
}
}
}
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
/// up differs by a whole dab between a slow and a fast pointer.
#[test]
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
let field = |step: f64| {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
};
let (slow, fast) = (field(2.0), field(60.0));
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
let mut f = LiquifyField::new(bounds(), 2.0);
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
let mut g = LiquifyField::new(bounds(), 2.0);
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
assert_eq!(f, g);
// Hostile points: nothing panics and the next segment starts afresh.
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
}
#[test]
fn a_segment_to_a_far_off_point_returns() {
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
@@ -1110,8 +1250,8 @@ mod tests {
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
#[test]
fn forward_warp_leaves_the_brush_rim_in_place() {
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 4.0);
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
+51 -5
View File
@@ -35,7 +35,8 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100 (% of `max(radius, 4 px)`).
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
pub shift_edge: f32,
}
@@ -59,8 +60,18 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
fn shift_amount(&self) -> f32 {
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
fn shift_px(&self) -> i32 {
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32
let px = self.shift_amount();
let reach = px.abs().ceil();
let signed = if px.is_sign_negative() { -reach } else { reach };
if signed.is_finite() { signed as i32 } else { 0 }
}
fn uses_image(&self) -> bool {
self.radius > 0.0
@@ -370,13 +381,38 @@ where
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
}
}
let d = p.shift_px();
if d != 0 {
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
let amount = p.shift_amount();
if amount.abs() >= 1.0e-4 {
a = shift_mask(&a, w, h, amount, morphology);
}
a
}
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
let mag = amount.abs();
if !mag.is_finite() || mag < 1.0e-4 {
return src.to_vec();
}
let grow = amount > 0.0;
let whole = mag.floor() as usize;
let frac = mag - whole as f32;
if frac < 1.0e-4 {
return morphology(src, w, h, whole, grow);
}
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
let hi = morphology(src, w, h, whole + 1, grow);
let n = w.saturating_mul(h);
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
return hi;
}
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
@@ -720,6 +756,16 @@ mod tests {
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 27], 1.0);
assert_eq!(shrunk[5 * w + 28], 0.0);
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
// The pixel just outside a hard edge fades in instead of snapping on.
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(one[5 * w + 30], 1.0);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
+1 -1
View File
@@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
+1 -1
View File
@@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
};
let spans = Spans::new(radius, shape_inside(shape));
let mut expected = conv_spans(&src, area, &ctx, &spans);
crate::mix_selection(&mut expected, area, &selection, &src);
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
let mut reference = surface.clone();
reference.write_region(area, &expected);
for tile in [17, 256] {
+1 -11
View File
@@ -167,17 +167,7 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
+1 -1
View File
@@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
let mut data = reference::iris(&src, t, &ctx, &pins);
crate::mix_selection(&mut data, t, &selection, &src);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
want.write_region(t, &data);
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
use photocraft_color::{PixelFormat, SampleType};
fn selection(coverage: f32) -> Surface {
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
}
#[test]
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
let r = Rect::new(0, 0, 1, 1);
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [0.5, 1.0] {
let format = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(format);
let mut p = vec![0.5; format.channels()];
*p.last_mut().unwrap() = alpha;
s.write_pixel(0, 0, &p);
let original = s.pixel(0, 0);
let a = format.channels() - 1;
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
let actual = out.pixel(0, 0);
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
for c in 0..a {
let before = original[c] * original[a] + 1.0 - original[a];
let after = actual[c] * actual[a] + 1.0 - actual[a];
assert!(
(before - after).abs() < tolerance,
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
);
}
}
}
}
}
}
#[test]
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
let r = Rect::new(0, 0, 2, 1);
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
for sample in SampleType::ALL {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 0);
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
}
}
#[test]
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
let r = Rect::new(0, 0, 3, 3);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
let full = apply(&s, &filter, r, r, None);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 1);
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
}
}
#[test]
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
let r = Rect::new(0, 0, 1, 1);
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
let filtered = vec![8.0, -4.0, 0.75, 1.0];
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(0.5), &original, true);
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
assert!((actual - expected).abs() < 1e-6, "{data:?}");
}
for coverage in [0.0, 1.0] {
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(coverage), &original, true);
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
}
}
#[test]
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
let r = Rect::new(0, 0, 1, 1);
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
for ch in [1, 3, 4] {
let original = Image { rect: r, ch, data: vec![0.25; ch] };
let mut data = vec![0.75; ch];
mix_selection(&mut data, r, &selection(0.25), &original, false);
assert_eq!(data, vec![0.375; ch]);
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
None => Image::read(&surface, t.inflate(radius)),
};
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
crate::mix_selection(&mut data, t, &selection, &src);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
want.write_region(t, &data);
}
}
+4 -127
View File
@@ -4,7 +4,8 @@
//! All work on straight (non-premultiplied) RGBA buffers in 0..=1, row-major `w × h`, so callers
//! convert any colour model / bit depth to RGBA first (as `Image › Adjustments` does for the
//! per-pixel adjustments). Formulas are documented approximations of Photoshop's behaviour,
//! derived from its manual and observation, not from its implementation.
//! derived from its manual and observation, not from its implementation; Shadows/Highlights is
//! measured on Photoshop (see [`shadows_highlights`]).
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
use serde::{Deserialize, Serialize};
@@ -17,18 +18,6 @@ fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Blurred luminance (Gaussian, `radius` px ≈ 2σ like Photoshop's radius fields).
fn blurred_luma(px: &[[f32; 4]], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]])).collect();
gauss_blur_n(&mut l, w, h, 1, radius.max(0.0) / 2.0);
l
}
/// Image › Adjustments › Shadows/Highlights parameters (Photoshop's dialog, percentages as 0..=100).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShadowsHighlights {
@@ -64,95 +53,8 @@ impl Default for ShadowsHighlights {
}
}
/// Local tone mapping: a blurred-luminance mask selects shadows (dark neighbourhoods, up to the
/// tonal width) and highlights; shadows are lifted and highlights pulled down by a gain that
/// preserves hue, then saturation is corrected where the gain moved tones, midtone contrast is
/// applied, and the result is stretched by the clip percentages.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() {
return;
}
let ls = blurred_luma(px, w, h, p.shadow_radius);
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) };
let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0);
let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01));
let color = p.color / 100.0;
let mid = p.midtone / 100.0;
par_rows(px, w, 1, |y, pxrow| {
for (x, q) in pxrow.iter_mut().enumerate() {
let i = y * w + x;
if q[3] <= 0.0 {
continue;
}
let c = [q[0], q[1], q[2]];
// Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width.
let ms = 1.0 - smoothstep(0.0, st, ls[i]);
let mh = smoothstep(1.0 - ht, 1.0, lh[i]);
let mut out = c;
if sa > 0.0 && ms > 0.0 {
// Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods.
let g = 1.0 / (1.0 + 2.0 * sa * ms);
out = out.map(|v| v.max(0.0).powf(g));
}
if ha > 0.0 && mh > 0.0 {
let g = 1.0 / (1.0 + 2.0 * ha * mh);
out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g));
}
if mid != 0.0 {
let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0);
out = out.map(|v| (v - 0.5) * k + 0.5);
}
let changed = (ms * sa + mh * ha).min(1.0);
if color != 0.0 && changed > 0.0 {
let l1 = luma(out);
let s = 1.0 + color * changed;
out = out.map(|v| l1 + (v - l1) * s);
}
q[0] = out[0].clamp(0.0, 1.0);
q[1] = out[1].clamp(0.0, 1.0);
q[2] = out[2].clamp(0.0, 1.0);
}
});
clip_stretch(px, p.black_clip, p.white_clip);
}
/// Stretches levels so `black`% of pixels clip to 0 and `white`% to 1 (luminance histogram).
fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = [0u32; 1024];
let mut n = 0u32;
for q in px.iter().filter(|q| q[3] > 0.0) {
hist[((luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)) * 1023.0).round() as usize] += 1;
n += 1;
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..1024 {
let i = if rev { 1023 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 1023.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let lo = find(black, false);
let hi = find(white, true);
if hi - lo < 1e-3 {
return;
}
for q in px.iter_mut().filter(|q| q[3] > 0.0) {
for v in &mut q[..3] {
*v = ((*v - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
}
mod shadows_highlights;
pub use shadows_highlights::shadows_highlights;
/// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels)
/// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it.
@@ -378,31 +280,6 @@ mod tests {
.collect()
}
#[test]
fn shadows_lift_dark_pixels_more_than_bright() {
let mut px = ramp(32, 8);
let orig = px.clone();
shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() });
let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]);
assert!(gain(3) > 0.02, "{}", gain(3));
assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30));
// Zero amounts and no clipping: identity.
let mut px2 = orig.clone();
let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px2, 32, 8, &id);
assert_eq!(px2, orig);
}
#[test]
fn highlights_darken_bright_pixels() {
let mut px = ramp(32, 8);
let orig = px.clone();
let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px, 32, 8, &p);
assert!(px[30][0] < orig[30][0]);
assert!((px[1][0] - orig[1][0]).abs() < 1e-6);
}
#[test]
fn replace_color_only_touches_matching_pixels() {
let mut px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]];
+423
View File
@@ -0,0 +1,423 @@
//! Image › Adjustments › Shadows/Highlights, as Photoshop 25.4 does it.
//!
//! Measured on Photoshop as a black box (synthetic 16-bit images through the adjustment, results
//! read back exactly); no Photoshop code was read. The pieces:
//!
//! - **Neighbourhood brightness.** Per pixel, the mean of its brightest channel and its Rec. 601
//! luma. Its neighbourhood value is an edge-aware average: a Gaussian of σ = 1.14 · Radius whose
//! weights fall linearly with the difference from the pixel's own (lightly blurred) brightness,
//! so pixels across a strong edge count less and halos stay small. It is quantised to 8 bits.
//! - **Tonal mask.** Tone sets which neighbourhoods are treated: shadows take the complement of a
//! Beta distribution function of the neighbourhood brightness, from Beta(1.95, 23) at Tone 0 to
//! 1 − L at Tone 95 and over; highlights take the same of 1 − L.
//! - **Curve.** Amount sets a curve that screens the image with itself: 1 − (1 − v)^q, blended
//! between q = 1, 2, 4 and 8 by a strength s = 3 at 100, 2 at 50 and 1 near 15. Shadows lift each
//! channel by mask × (curve − v); highlights darken with the mirrored curve.
//! - **Colour.** Only the luma of that result is kept: the pixel's own colour differences are
//! added back, scaled by the Color slider (by exp(h · ΔY / max Y) for positive values, by
//! 1 + h · ΔY / max Y for negative ones) and shortened where they would leave the gamut.
//! - **Midtone** is a global S-curve on the luma, k · (Y − ½) · (4Y(1 − Y))^p.
use crate::fxutil::gauss_blur_n;
use crate::photo_util::par_rows;
use super::ShadowsHighlights;
const W: [f32; 3] = [0.299, 0.587, 0.114];
fn luma(c: [f32; 3]) -> f32 {
W[0] * c[0] + W[1] * c[1] + W[2] * c[2]
}
/// Neighbourhood σ per pixel of Radius.
const SPATIAL: f32 = 1.1424;
/// σ of the blur of the pixel's own brightness the edge weights compare with.
const GUIDE: f32 = 1.3559;
/// Brightness difference at which a neighbour's weight reaches zero.
const RANGE: f32 = 1.0152;
/// Brightness levels of the piecewise-linear edge-aware average.
const LEVELS: usize = 17;
/// Edge-aware neighbourhood brightness of `lum` (`w × h`) for Photoshop's Radius.
fn neighbourhood(lum: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut guide = lum.to_vec();
gauss_blur_n(&mut guide, w, h, 1, GUIDE);
let sigma = SPATIAL * radius;
if sigma < 0.3 {
return guide;
}
// The average at each of a few brightness levels, interpolated at the pixel's own level
// (Durand and Dorsey, "Fast bilateral filtering", 2002). The averages are smooth, so they are
// taken on a grid `d` times coarser, with σ ≥ 4 cells there.
let d = ((sigma / 4.0).floor() as usize).max(1);
let (rw, rh) = (w.div_ceil(d), h.div_ceil(d));
let cells = rw * rh;
let step = 1.0 / (LEVELS - 1) as f32;
let mut avg = vec![0.0f32; LEVELS * cells];
let mut buf = vec![0.0f32; 2 * cells];
for (k, out) in avg.chunks_exact_mut(cells).enumerate() {
let g = k as f32 * step;
buf.fill(0.0);
for (i, &l) in lum.iter().enumerate() {
let wgt = (1.0 - (l - g).abs() / RANGE).max(0.0);
let c = 2 * ((i / w) / d * rw + (i % w) / d);
buf[c] += wgt * l;
buf[c + 1] += wgt;
}
gauss_blur_n(&mut buf, rw, rh, 2, sigma / d as f32);
for (o, b) in out.iter_mut().zip(buf.as_chunks::<2>().0) {
*o = if b[1] > 1e-6 { b[0] / b[1] } else { g };
}
}
let mut out = vec![0.0f32; lum.len()];
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy.fract());
let y1 = (y0 + 1).min(rh - 1);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx.fract());
let x1 = (x0 + 1).min(rw - 1);
let at = |k: usize| {
let a = &avg[k * cells..(k + 1) * cells];
let top = a[y0 * rw + x0] + (a[y0 * rw + x1] - a[y0 * rw + x0]) * tx;
let bot = a[y1 * rw + x0] + (a[y1 * rw + x1] - a[y1 * rw + x0]) * tx;
top + (bot - top) * ty
};
let t = (guide[y * w + x].clamp(0.0, 1.0) / step).min((LEVELS - 1) as f32 - 1e-4);
let k = t.floor() as usize;
let f = t - k as f32;
*o = at(k) * (1.0 - f) + at(k + 1) * f;
}
});
out
}
/// Beta(α, β) parameters of the tonal mask for Tone 0, 5, …, 95 (fitted to Photoshop's masks
/// within 2/255).
const MASK_BETA: [(f32, f32); 20] = [
(1.9468, 22.9366),
(1.7770, 18.3471),
(1.6403, 14.7559),
(1.5270, 11.9346),
(1.4269, 9.6467),
(1.3470, 7.8430),
(1.2807, 6.4038),
(1.2240, 5.2384),
(1.1772, 4.2967),
(1.1373, 3.5350),
(1.1021, 2.9109),
(1.0755, 2.4186),
(1.0508, 2.0189),
(1.0307, 1.7034),
(1.0140, 1.4583),
(1.0015, 1.2758),
(1.0, 1.1403),
(1.0, 1.0510),
(1.0, 1.0002),
(1.0, 1.0),
];
/// The tonal mask for `tone` (0..=100) at the 256 neighbourhood levels: 1 − I_L(α, β).
fn tonal_mask(tone: f32) -> [f32; 256] {
let t = (tone.clamp(0.0, 95.0)) / 5.0;
let i = (t.floor() as usize).min(MASK_BETA.len() - 2);
let f = t - i as f32;
let (a0, b0) = MASK_BETA[i];
let (a1, b1) = MASK_BETA[i + 1];
let (a, b) = ((a0 + (a1 - a0) * f) as f64, (b0 + (b1 - b0) * f) as f64);
// Midpoint sums of the Beta density, 16 per level.
const SUB: usize = 16;
let n = 255 * SUB;
let dens = |x: f64| ((a - 1.0) * x.ln() + (b - 1.0) * (1.0 - x).ln()).exp();
let mut cum = vec![0.0f64; 256];
let mut acc = 0.0;
for j in 0..n {
acc += dens((j as f64 + 0.5) / n as f64);
if (j + 1) % SUB == 0 {
cum[(j + 1) / SUB] = acc;
}
}
let total = acc.max(f64::MIN_POSITIVE);
std::array::from_fn(|k| (1.0 - cum[k] / total) as f32)
}
/// Curve strength for Amount `a` (0..=100): 0 at 0, 1 near 15, 2 at 50, 3 at 100.
fn strength(a: f32) -> f32 {
let u = (a.clamp(0.0, 100.0) / 100.0).powf(0.6216);
if u <= 0.3 { u / 0.3 } else { (1.0 + 20.0 * u) / 7.0 }
}
/// The full-strength curve of strength `s`: 1 − (1 − v)^q between q = 2^⌊s⌋ and 2^(⌊s⌋+1).
fn lift(s: f32, v: f32) -> f32 {
let n = (s.floor() as i32).clamp(0, 2);
let t = (s - n as f32).clamp(0.0, 1.0);
let c = (1.0 - v).clamp(0.0, 1.0);
let lo = c.powi(1 << n);
let hi = lo * lo;
1.0 - ((1.0 - t) * lo + t * hi)
}
/// Linear interpolation in a table of `(x, y)` sorted by `x`.
fn interp(table: &[(f32, f32)], x: f32) -> f32 {
let Some(&(x0, y0)) = table.first() else { return 0.0 };
if x <= x0 {
return y0;
}
for p in table.windows(2) {
let ((xa, ya), (xb, yb)) = (p[0], p[1]);
if x <= xb {
return ya + (yb - ya) * (x - xa) / (xb - xa);
}
}
table.last().map_or(0.0, |p| p.1)
}
/// Color: the h of the colour-difference gain for positive values (exp(h · F))…
const COLOR_UP: [(f32, f32); 12] = [
(0.0, 0.0),
(1.0, 0.1566),
(2.0, 0.2271),
(5.0, 0.3776),
(10.0, 0.5653),
(20.0, 0.8669),
(30.0, 1.1299),
(40.0, 1.3748),
(50.0, 1.6093),
(60.0, 1.8375),
(75.0, 2.1721),
(100.0, 2.7193),
];
/// … and for negative values (1 + h · F), by −Color.
const COLOR_DOWN: [(f32, f32); 9] =
[(0.0, 0.0), (1.0, -0.0145), (5.0, -0.0709), (10.0, -0.1373), (20.0, -0.2570), (30.0, -0.3612), (50.0, -0.5312), (75.0, -0.6866), (100.0, -0.7968)];
/// Midtone: (value, k, p) of k · (Y − ½) · (4Y(1 − Y))^p.
const MIDTONE: [(f32, f32, f32); 11] = [
(-100.0, -0.6955, 2.267),
(-75.0, -0.5873, 2.362),
(-50.0, -0.4429, 2.492),
(-25.0, -0.2514, 2.667),
(-10.0, -0.1088, 2.803),
(0.0, 0.0, 2.9),
(10.0, 0.1206, 3.016),
(25.0, 0.3262, 3.216),
(50.0, 0.7423, 3.625),
(75.0, 1.2635, 4.160),
(100.0, 1.9032, 4.850),
];
fn midtone(m: f32, y: f32) -> f32 {
if m == 0.0 {
return y;
}
let ks: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.1)).collect();
let ps: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.2)).collect();
let (k, p) = (interp(&ks, m), interp(&ps, m));
(y + k * (y - 0.5) * (4.0 * y * (1.0 - y)).max(0.0).powf(p)).clamp(0.0, 1.0)
}
fn level(v: f32) -> usize {
(v.clamp(0.0, 1.0) * 255.0).round() as usize
}
/// Image › Adjustments › Shadows/Highlights on straight RGBA `w × h`.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() || px.len() != w * h {
return;
}
let (sa, ha) = (p.shadow_amount, p.highlight_amount);
let lum: Vec<f32> = px.iter().map(|q| (q[0].max(q[1]).max(q[2]) + luma([q[0], q[1], q[2]])) / 2.0).collect();
let ls = (sa > 0.0).then(|| neighbourhood(&lum, w, h, p.shadow_radius));
let lh = (ha > 0.0).then(|| match &ls {
Some(l) if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 => l.clone(),
_ => neighbourhood(&lum, w, h, p.highlight_radius),
});
let (ms, mh) = (tonal_mask(p.shadow_tone), tonal_mask(p.highlight_tone));
let (ss, hs) = (strength(sa), strength(ha));
// The new luma of every pixel.
let mut y2 = vec![0.0f32; px.len()];
par_rows(&mut y2, w, 1, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
let i = y * w + x;
let q = px[i];
let mut adj = [q[0], q[1], q[2]];
if let Some(l) = &ls {
let m = ms[level(l[i])];
for v in &mut adj {
*v += m * (lift(ss, *v) - *v);
}
}
if let Some(l) = &lh {
let m = mh[255 - level(l[i])];
for (v, &o) in adj.iter_mut().zip(&q[..3]) {
*v -= m * (lift(hs, 1.0 - o) - (1.0 - o));
}
}
*out = midtone(p.midtone, luma(adj).clamp(0.0, 1.0));
}
});
clip_levels(&mut y2, px, p.black_clip, p.white_clip);
let gain = |f: f32| {
if p.color >= 0.0 { (interp(&COLOR_UP, p.color) * f).exp() } else { 1.0 + interp(&COLOR_DOWN, -p.color) * f }
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
if q[3] <= 0.0 {
continue;
}
let y1 = luma([q[0], q[1], q[2]]);
let yn = y2[y * w + x];
let f = (yn - y1).abs() / yn.max(y1).max(1e-6);
let g = gain(f);
let d = [(q[0] - y1) * g, (q[1] - y1) * g, (q[2] - y1) * g];
// Shorten the colour difference where it would leave 0..=1.
let mut k = 1.0f32;
for v in d {
if v > 1e-9 {
k = k.min((1.0 - yn) / v);
} else if v < -1e-9 {
k = k.min(yn / -v);
}
}
let k = k.clamp(0.0, 1.0);
for c in 0..3 {
q[c] = (yn + k * d[c]).clamp(0.0, 1.0);
}
}
});
}
/// Black and White Clip: stretches the new luma so `black`% of pixels reach 0 and `white`% reach 1.
fn clip_levels(y2: &mut [f32], px: &[[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = vec![0u32; 4096];
let mut n = 0u32;
for (&y, q) in y2.iter().zip(px) {
if q[3] > 0.0 {
hist[(y.clamp(0.0, 1.0) * 4095.0).round() as usize] += 1;
n += 1;
}
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..4096 {
let i = if rev { 4095 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 4095.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let (lo, hi) = (if black > 0.0 { find(black, false) } else { 0.0 }, if white > 0.0 { find(white, true) } else { 1.0 });
if hi - lo < 1e-3 {
return;
}
for y in y2.iter_mut() {
*y = ((*y - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A flat `w × h` grey or colour image with one different pixel in the middle; returns that
/// pixel after Shadows/Highlights.
fn probe(bg: [f32; 3], px: [f32; 3], p: &ShadowsHighlights) -> [f32; 3] {
let (w, h) = (121, 121);
let mut img = vec![[bg[0], bg[1], bg[2], 1.0]; w * h];
img[60 * w + 60] = [px[0], px[1], px[2], 1.0];
shadows_highlights(&mut img, w, h, p);
let q = img[60 * w + 60];
[q[0], q[1], q[2]]
}
fn levels(v: [i32; 3]) -> [f32; 3] {
v.map(|c| c as f32 / 32768.0)
}
fn off(a: [f32; 3], b: [f32; 3]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y).abs() * 255.0).fold(0.0, f32::max)
}
/// Photoshop 25.4's results (16-bit levels of 32768) for grey pixels lifted by the default
/// Shadows (35 %, Tone 50 %, Radius 30 px; Color 0), in black surroundings and in grey 0.5.
#[test]
fn grey_matches_photoshop() {
let p = ShadowsHighlights { color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, want) in [(0.0, [11389, 19422, 28451, 31931]), (0.5, [5211, 9909, 18229, 25700])] {
for (v, w) in [0.125f32, 0.25, 0.5, 0.749969].into_iter().zip(want) {
let got = probe([bg; 3], [v; 3], &p);
assert!(off(got, levels([w; 3])) < 2.0, "bg {bg} v {v}: {} vs {}", got[0] * 32768.0, w);
}
}
}
/// Colour pixels keep their colour differences, scaled by Color (+20 here; Amount 35 %,
/// Radius 10 px), in black, 0.15 and 0.3 grey surroundings: Photoshop's results.
#[test]
fn colour_matches_photoshop() {
let p = ShadowsHighlights { shadow_radius: 10.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, px, want) in [
(0.0, [3277, 1638, 0], [8235, 5202, 2170]),
(0.0, [8192, 6554, 4915], [19350, 16514, 13676]),
(4915.0 / 32768.0, [4915, 3277, 1638], [10124, 7424, 4723]),
(9830.0 / 32768.0, [11469, 9830, 8192], [16540, 14376, 12214]),
] {
let got = probe([bg; 3], levels(px), &p);
assert!(off(got, levels(want)) < 2.0, "{px:?} on {bg}: {:?} vs {want:?}", got.map(|c| (c * 32768.0).round()));
}
}
/// Across a strong edge the neighbourhood barely leaks (Amount 100 %, Tone 100 %, Radius
/// 10 px; 0.1 | 0.9): Photoshop's results 8, 2 and 1 pixels either side.
#[test]
fn edge_matches_photoshop() {
let (w, h) = (200, 24);
let (a, b) = (3277.0 / 32768.0, 29491.0 / 32768.0);
let mut img: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 100 { [a, a, a, 1.0] } else { [b, b, b, 1.0] }).collect();
let p = ShadowsHighlights {
shadow_amount: 100.0,
shadow_tone: 100.0,
shadow_radius: 10.0,
color: 0.0,
black_clip: 0.0,
white_clip: 0.0,
..Default::default()
};
shadows_highlights(&mut img, w, h, &p);
let row = &img[12 * w..13 * w];
for (x, want) in [(92, 16370), (98, 14439), (99, 12146), (100, 30891), (101, 30403), (102, 30146), (108, 29979)] {
let got = row[x][0] * 32768.0;
assert!((got - want as f32).abs() / 32768.0 * 255.0 < 6.0, "x {x}: {got} vs {want}");
}
}
#[test]
fn strength_and_curve_follow_photoshop() {
assert!((strength(50.0) - 2.0).abs() < 2e-3 && (strength(100.0) - 3.0).abs() < 1e-4);
assert!((strength(15.0) - 1.02).abs() < 0.01 && (strength(1.0) - 0.19).abs() < 0.01);
// Amount 50 is the image screened with itself twice: 1 − (1 − v)^4.
for v in [0.1f32, 0.3, 0.6] {
assert!((lift(2.0, v) - (1.0 - (1.0 - v).powi(4))).abs() < 1e-6);
}
}
#[test]
fn tonal_mask_spans_photoshop_tones() {
let m95 = tonal_mask(95.0);
assert!((m95[128] - (1.0 - 128.0 / 255.0)).abs() < 1e-3, "{}", m95[128]);
assert_eq!(tonal_mask(100.0), m95);
// Tone 0 still reaches about 7 % into the levels (Photoshop: half at level 18).
let m0 = tonal_mask(0.0);
assert!(m0[0] > 0.999 && (m0[18] - 0.5).abs() < 0.03 && m0[60] < 0.02, "{} {}", m0[18], m0[60]);
}
}
+40 -1
View File
@@ -368,7 +368,8 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
any = true;
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
for c in 0..a {
outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
// Integer storage clips on write; float colours retain their signed/HDR range.
outp[o + c] = (acc[c] / al) as f32;
}
outp[o + a] = al as f32;
}
@@ -482,6 +483,44 @@ mod tests {
}
}
#[test]
fn translation_preserves_colour_range_at_every_depth() {
use photocraft_color::{ColorMode, SampleType};
let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]);
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [false, true] {
let mut s = Surface::new(PixelFormat::new(mode, sample, alpha));
let mut pixel = if mode == ColorMode::Rgb { vec![2.0, -0.25, 0.5] } else { vec![2.0] };
if alpha {
pixel.push(0.5);
}
s.fill_rect(Rect::new(4, 4, 8, 8), &pixel);
let mut expected = s.pixel(5, 5);
if !alpha {
expected.push(1.0);
}
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
assert_eq!(out.pixel(12, 2), expected, "{sample:?}/{mode:?}/alpha={alpha}/{interp:?}");
assert_eq!(out.pixel(0, 0).last(), Some(&0.0));
}
}
}
}
}
#[test]
fn bilinear_translation_interpolates_signed_hdr_colours() {
let mut s = Surface::new(PixelFormat::RGBA32F);
s.write_pixel(0, 0, &[2.0, -0.5, 0.25, 0.5]);
s.write_pixel(1, 0, &[4.0, -0.25, 0.75, 0.5]);
let h = Homography([1.0, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let out = warp_surface(&s, s.content_bounds(), &h, Interp::Bilinear).unwrap();
assert_eq!(out.pixel(1, 0), vec![3.0, -0.375, 0.5, 0.5]);
}
#[test]
fn scale_doubles_size_and_rotation_keeps_area() {
let mut s = rgba();
+93 -14
View File
@@ -6,7 +6,9 @@
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp folds over itself the
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
@@ -25,6 +27,9 @@ fn catmull_rom(t: f64) -> [f64; 4] {
const CELL: f64 = 4.0;
/// Most cells along one axis (bounds memory and time on huge layers).
const MAX_CELLS: usize = 512;
/// Most subsamples per axis when a warp shrinks the source; also the longest source step per
/// destination pixel the filter follows (bounds the footprint each tile reads).
const MAX_SS: f64 = 8.0;
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
@@ -172,14 +177,37 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let w = t.width() as usize;
let h = t.height() as usize;
// Source position per destination pixel (NaN = not covered).
// Subsamples along one destination axis, from the source step `j` it makes: one where
// the warp keeps or enlarges the source, else about one per source pixel the output pixel
// spans (an area average, as the transform path gets from its pre-reduction; a single
// bicubic sample aliases thin lines when shrinking). The subsamples are bilinear: the
// box does the filtering, and it is ~4x cheaper than bicubic at no loss.
let count = |j: [f64; 2]| if interp == Interp::Nearest { 1 } else { j[0].hypot(j[1]).round().clamp(1.0, MAX_SS) as usize };
// Source position per destination pixel (NaN = not covered) and the triangle it came
// from. Per triangle: its source step per destination pixel along x and y (`[dx_u, dx_v,
// dy_u, dy_v]`, the inverse map's Jacobian, constant over a triangle; zero when
// point-sampled) and the subsamples along each.
let mut uv = vec![[f64::NAN; 2]; w * h];
for tri in tris {
let mut owner = vec![0u32; w * h];
let mut jac: Vec<([f64; 4], usize, usize)> = Vec::with_capacity(tris.len());
for (k, tri) in tris.iter().enumerate() {
let [(p0, s0), (p1, s1), (p2, s2)] = tri.map(|v| verts[v]);
let det = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]);
if det.abs() < 1e-12 {
jac.push(([0.0; 4], 1, 1));
continue;
}
let (d1, d2) = ([s1[0] - s0[0], s1[1] - s0[1]], [s2[0] - s0[0], s2[1] - s0[1]]);
let (l1x, l1y, l2x, l2y) = ((p2[1] - p0[1]) / det, (p0[0] - p2[0]) / det, (p0[1] - p1[1]) / det, (p1[0] - p0[0]) / det);
// Capped, so a sliver triangle can't ask for an unbounded footprint.
let cap = |j: [f64; 2]| {
let l = j[0].hypot(j[1]);
if l > MAX_SS { [j[0] * MAX_SS / l, j[1] * MAX_SS / l] } else { j }
};
let jx = cap([l1x * d1[0] + l2x * d2[0], l1x * d1[1] + l2x * d2[1]]);
let jy = cap([l1y * d1[0] + l2y * d2[0], l1y * d1[1] + l2y * d2[1]]);
let (nx, ny) = (count(jx), count(jy));
jac.push(if nx * ny == 1 { ([0.0; 4], 1, 1) } else { ([jx[0], jx[1], jy[0], jy[1]], nx, ny) });
let bx0 = (p0[0].min(p1[0]).min(p2[0]) - 0.5).floor().max(f64::from(t.x0)) as i32;
let by0 = (p0[1].min(p1[1]).min(p2[1]) - 0.5).floor().max(f64::from(t.y0)) as i32;
let bx1 = (p0[0].max(p1[0]).max(p2[0]) + 0.5).ceil().min(f64::from(t.x1)) as i32;
@@ -197,16 +225,18 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let o = (y - t.y0) as usize * w + (x - t.x0) as usize;
uv[o] = [l0 * s0[0] + l1 * s1[0] + l2 * s2[0], l0 * s0[1] + l1 * s1[1] + l2 * s2[1]];
owner[o] = k as u32;
}
}
}
// Source footprint of the covered pixels.
// Source footprint of the covered pixels, including the spread of their subsamples.
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in uv.iter().filter(|q| !q[0].is_nan()) {
fx0 = fx0.min(q[0]);
fy0 = fy0.min(q[1]);
fx1 = fx1.max(q[0]);
fy1 = fy1.max(q[1]);
for (q, j) in uv.iter().zip(&owner).filter(|(q, _)| !q[0].is_nan()).map(|(q, &k)| (q, jac[k as usize].0)) {
let (ex, ey) = (0.5 * (j[0].abs() + j[2].abs()), 0.5 * (j[1].abs() + j[3].abs()));
fx0 = fx0.min(q[0] - ex);
fy0 = fy0.min(q[1] - ey);
fx1 = fx1.max(q[0] + ex);
fy1 = fy1.max(q[1] + ey);
}
if fx0 > fx1 {
return None;
@@ -233,11 +263,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
for (o, q) in uv.iter().enumerate() {
if q[0].is_nan() {
continue;
}
let (u, v) = (q[0] - 0.5, q[1] - 0.5);
let mut one = [0.0f64; 8];
// One premultiplied sample at source position (u, v) (pixel centres at integers).
let point = |interp: Interp, u: f64, v: f64, acc: &mut [f64; 8]| {
acc[..n].fill(0.0);
match interp {
Interp::Nearest => {
@@ -276,6 +304,31 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
}
}
};
for (o, (q, &k)) in uv.iter().zip(&owner).enumerate() {
if q[0].is_nan() {
continue;
}
let (j, nx, ny) = jac[k as usize];
if nx * ny == 1 {
point(interp, q[0] - 0.5, q[1] - 0.5, &mut acc);
} else {
acc[..n].fill(0.0);
for sy in 0..ny {
let t = (sy as f64 + 0.5) / ny as f64 - 0.5;
for sx in 0..nx {
let s = (sx as f64 + 0.5) / nx as f64 - 0.5;
point(Interp::Bilinear, q[0] + s * j[0] + t * j[2] - 0.5, q[1] + s * j[1] + t * j[3] - 0.5, &mut one);
for (c, v) in acc.iter_mut().zip(&one).take(n) {
*c += v;
}
}
}
let k = 1.0 / (nx * ny) as f64;
for c in acc.iter_mut().take(n) {
*c *= k;
}
}
let al = acc[a].clamp(0.0, 1.0);
// Float noise (a vertex a hair off the pixel grid) must not leave invisible
// colour behind in transparent pixels.
@@ -407,6 +460,32 @@ mod tests {
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
}
#[test]
fn a_shrinking_mesh_warp_is_prefiltered() {
// A warped smart object placed at ~1/5 size: a grid of 1 px lines every 4 px must come
// out its area average (9/16 white) everywhere, not point-sampled moiré (crinkled,
// doubled or missing lines), like the affine path's `large_downscale_is_prefiltered`.
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
let (w, h) = (400, 320);
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, s, true));
let px: Vec<f32> =
(0..h).flat_map(|y| (0..w).map(move |x| if x % 4 == 0 || y % 4 == 0 { 0.0 } else { 1.0 })).flat_map(|v| [v, v, v, 1.0]).collect();
let r = Rect::new(0, 0, w, h);
src.write_region(r, &px);
let warp = Warp::preset(WarpStyle::Arc, 15.0, [0.0, 0.0, f64::from(w), f64::from(h)]);
let t = photocraft_geom::Affine { m: [0.2, 0.0, 0.0, 0.2, 7.0, 5.0] };
let out = place_source(&src, r, &t, Some(&warp)).unwrap();
let b = out.content_bounds();
let o = out.read_region(b);
let opaque: Vec<f32> = o.as_chunks::<4>().0.iter().filter(|p| p[3] >= 0.999).map(|p| p[0]).collect();
assert!(opaque.len() > 2000, "{s:?}: {} opaque pixels", opaque.len());
let worst = opaque.iter().map(|v| (v - 0.5625).abs()).fold(0.0f32, f32::max);
// An area average over the ~5 px footprint still covers one or two lines per axis
// (up to 0.2 off); point sampling lands wholly on or off a line (up to 0.56 off).
assert!(worst < 0.25, "{s:?}: worst deviation from the area average {worst}");
}
}
#[test]
fn gray_masks_keep_their_default() {
let mut m = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
+6 -17
View File
@@ -96,13 +96,15 @@ impl Headless {
st.source_read_only = o.source_read_only;
}
let d = &self.session.documents()[index];
let mut warnings = o.warnings;
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&d.doc));
Ok(json!({
"index": index,
"name": d.doc.name,
"width": d.doc.size.width,
"height": d.doc.size.height,
"layers": d.doc.layer_count(),
"warnings": o.warnings,
"warnings": warnings,
}))
}
@@ -240,7 +242,7 @@ impl Headless {
}
};
let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
let warnings = match &self.filesystem {
let mut warnings = match &self.filesystem {
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
Filesystem::TrustedLocal => {
let writer = self.writers.entry(doc.id.0).or_default();
@@ -252,6 +254,7 @@ impl Headless {
warnings
}
};
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc));
// A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy:
// record the current revision as saved so the session stops reporting `dirty`, and make
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
@@ -317,21 +320,7 @@ impl Headless {
}
pub fn command_list(&self) -> Value {
Value::Array(
command_specs()
.iter()
.map(|c| {
json!({
"id": c.id,
"label": c.label,
"menu": c.menu,
"shortcut": c.shortcut,
"params": c.params,
"enabled": self.session.is_enabled(c.id),
})
})
.collect(),
)
Value::Array(command_specs().iter().map(|c| photocraft_engine::commands::list_entry(&self.session, c, self.session.disabled_reason(c.id))).collect())
}
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
+32 -2
View File
@@ -30,7 +30,7 @@ use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line,
read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
@@ -288,7 +288,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
LineRead::Line => {}
}
let reply = if authenticated {
respond(h, &line)
repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line))
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
@@ -780,6 +780,36 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
});
let addr = rx.recv().unwrap();
let mut s = std::net::TcpStream::connect(addr).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["id"], "m");
assert_eq!(v["ok"], true);
}
#[test]
fn tcp_rejects_requests_before_authentication() {
use std::io::{BufReader, Write as _};
+23
View File
@@ -118,6 +118,18 @@ pub fn authentication_reply(line: &str, expected_token: &str) -> (Value, bool) {
}
}
/// Answer a repeat `auth` frame on an already authenticated connection. Returns `None` for any
/// other frame, which the caller dispatches as usual.
/// Without this, a second `auth` fell through to the method dispatcher as an unknown method.
pub fn repeat_auth_reply(line: &str) -> Option<Value> {
let req: Value = serde_json::from_str(line).ok()?;
if req.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
return None;
}
let id = req.get("id").cloned().unwrap_or(Value::Null);
Some(json!({"id": id, "ok": false, "error": "already authenticated"}))
}
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let token = token.trim().to_owned();
@@ -294,6 +306,17 @@ mod tests {
assert_eq!(reply["result"]["authenticated"], true);
}
#[test]
fn repeat_auth_on_an_authenticated_connection_says_so() {
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
let reply = repeat_auth_reply(&line).unwrap();
assert_eq!(reply["id"], 3);
assert_eq!(reply["ok"], false);
assert_eq!(reply["error"], "already authenticated");
assert!(repeat_auth_reply(r#"{"id":4,"method":"methods"}"#).is_none());
assert!(repeat_auth_reply("{not json").is_none());
}
#[test]
fn token_file_round_trips_between_server_and_client() {
let path = std::env::temp_dir().join(format!("photocraft-control-token-{}-{}.txt", std::process::id(), generate_token().unwrap()));
+3 -1
View File
@@ -792,7 +792,9 @@ impl PhotocraftMcp {
}
}
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")]
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \
`disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \
document is open: create or open one first (doc_new, doc_open).")]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await {
r
+99
View File
@@ -343,3 +343,102 @@ async fn agent_play_refuses_a_recorded_file_open() {
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges,
/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round
/// trip — all through MCP tools and checked from outside the engine.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_ten_more_scripted_tasks() {
let dir = tmp("tasks2");
let (c, server) = connect(&dir).await;
// 11. Stroke a selection: a red square with a black outside stroke.
tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await;
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await;
let inside = pixel(&c, 40, 40).await;
let edge = pixel(&c, 18, 40).await;
let away = pixel(&c, 5, 5).await;
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}");
assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}");
assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}");
run(&c, "select.deselect", json!({})).await;
// 12. Inverse the selection: a blue frame around the red square.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
let centre = pixel(&c, 40, 40).await;
let frame = pixel(&c, 4, 4).await;
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}");
assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}");
run(&c, "select.deselect", json!({})).await;
// 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default).
run(&c, "layer.new.layer", json!({"name": "Patch"})).await;
run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await;
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.duplicate", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}");
let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count();
assert_eq!(patches, 2, "task 13: {doc}");
// 14. Merge the copy down; the two patches become one layer.
run(&c, "layer.mergeDown", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14");
// 15. Flatten the image; the patch survives.
run(&c, "layer.flattenImage", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}");
let green = pixel(&c, 20, 20).await;
assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}");
// 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels.
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16");
let shifted = pixel(&c, 40, 40).await;
assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}");
run(&c, "layer.delete", json!({})).await;
// 17. Feather the selection and reveal a mask through it: the edge blends.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.modify.feather", json!({"radius": 4})).await;
run(&c, "layer.layerMask.revealSelection", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
let boundary = pixel(&c, 20, 40).await;
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
run(&c, "select.deselect", json!({})).await;
// 18. Expand a selection and read its bounds back.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
run(&c, "select.modify.expand", json!({"radius": 5})).await;
let bounds = inspect(&c).await["selectionBounds"].clone();
let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}"));
let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap());
assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}");
run(&c, "select.deselect", json!({})).await;
// 19. Rename a layer and delete it again.
run(&c, "layer.new.layer", json!({"name": "Scratch"})).await;
run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await;
assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19");
run(&c, "layer.delete", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted");
// 20. Export the final image and reopen the PNG as a flat document.
tool(&c, "doc_export", json!({"path": "final.png"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "final.png"})).await;
let doc = inspect(&c).await;
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20");
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+184
View File
@@ -0,0 +1,184 @@
//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD).
//!
//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked
//! through `doc_inspect`, `document.pixel` and exported/saved files.
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
use rmcp::service::RunningService;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use serde_json::{Value, json};
#[derive(Clone, Default)]
struct Client;
impl ClientHandler for Client {
fn get_info(&self) -> ClientConfig {
ClientConfig::default()
}
}
type Conn = RunningService<RoleClient, Client>;
type Server = tokio::task::JoinHandle<()>;
async fn connect(root: &std::path::Path) -> (Conn, Server) {
let (s, c) = tokio::io::duplex(1 << 20);
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
let server = tokio::spawn(async move {
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
let _ = running.waiting().await;
}
});
(Client.serve(c).await.expect("client init"), server)
}
async fn shutdown(c: Conn, server: Server) {
c.cancel().await.unwrap();
server.await.unwrap();
}
fn text(r: &CallToolResult) -> String {
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
}
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
let mut p = CallToolRequestParams::new(name.to_owned());
if let Value::Object(m) = args {
p = p.with_arguments(m);
}
let r = c.call_tool(p).await.expect("call_tool transport");
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
}
async fn run(c: &Conn, id: &str, params: Value) -> Value {
tool(c, "command_run", json!({"id": id, "params": params})).await
}
async fn inspect(c: &Conn) -> Value {
tool(c, "doc_inspect", json!({})).await
}
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
}
fn top_layer(doc: &Value) -> &Value {
&doc["layers"][0]
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale
/// conversion with an export check, a native save round trip that preserves type, a layer-style
/// stroke and inverse fills.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_six_rounding_out_tasks() {
let dir = tmp("tasks4");
let (c, server) = connect(&dir).await;
// 31. Undo/redo sweep: four fills go away and come back.
tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await;
let h0 = inspect(&c).await["history"].as_array().unwrap().len();
let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"];
for (i, color) in colors.iter().enumerate() {
let x = 10 * i as i32;
run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await;
run(&c, "edit.fill", json!({"color": color})).await;
}
run(&c, "select.deselect", json!({})).await;
let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0;
assert!(steps >= 4, "at least the four fills are history steps: {steps}");
for _ in 0..steps {
run(&c, "edit.undo", json!({})).await;
}
for i in 0..4 {
let x = 10 * i + 5;
let p = pixel(&c, x, 5).await;
assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}");
}
for _ in 0..steps {
run(&c, "edit.redo", json!({})).await;
}
let red = pixel(&c, 5, 5).await;
let black = pixel(&c, 35, 5).await;
assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}");
assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}");
// 32. Paint a barcode: five black bars on white.
tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await;
for i in 0..5 {
let x = 4 + 8 * i;
run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await;
run(&c, "edit.fill", json!({"color": "#000000"})).await;
}
run(&c, "select.deselect", json!({})).await;
for i in 0..5 {
let bar = pixel(&c, 5 + 8 * i, 12).await;
let gap = pixel(&c, 9 + 8 * i, 12).await;
assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}");
assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}");
}
// 33. Convert to grayscale: the channels flatten and the export keeps them equal.
run(&c, "image.mode.grayscale", json!({})).await;
assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33");
let g = pixel(&c, 5, 12).await;
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}");
tool(&c, "doc_export", json!({"path": "bars.png"})).await;
let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap();
let px = img.to_rgba8();
let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4];
assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas");
tool(&c, "doc_close", json!({})).await;
// 34. A native save round trip preserves a styled type layer.
tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await;
run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await;
tool(&c, "doc_save", json!({"path": "type.pcraft"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "type.pcraft"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}");
assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}");
tool(&c, "doc_close", json!({})).await;
// 35. A layer-style stroke frames a swatch.
tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await;
run(&c, "layer.new.layer", json!({"name": "Swatch"})).await;
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}");
let border = pixel(&c, 11, 24).await;
let centre = pixel(&c, 24, 24).await;
assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}");
// 36. Inverse fill: gray around the swatch, then Select All paints everything white.
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#808080"})).await;
run(&c, "select.deselect", json!({})).await;
let corner = pixel(&c, 2, 2).await;
let centre = pixel(&c, 24, 24).await;
assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}");
run(&c, "select.all", json!({})).await;
run(&c, "edit.fill", json!({"color": "#ffffff"})).await;
run(&c, "select.deselect", json!({})).await;
let white = pixel(&c, 2, 2).await;
assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+6
View File
@@ -170,6 +170,12 @@ async fn command_list_filters() {
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
// The full list says why a document command is off with no document open (#3664).
let layer = all.as_array().unwrap().iter().find(|c| c["id"] == "layer.new.layer").unwrap();
assert_eq!((layer["enabled"].clone(), layer["needsDocument"].clone()), (json!(false), json!(true)), "{layer}");
assert!(layer["disabledReason"].as_str().is_some_and(|r| r.starts_with("no document")), "{layer}");
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((new["needsDocument"].clone(), new["disabledReason"].clone()), (json!(false), Value::Null), "{new}");
client.cancel().await.unwrap();
}
@@ -0,0 +1,57 @@
//! #3391: `doc.save`, `doc.open`, and type commands emit warnings when text uses a missing font.
use photocraft_automation::Headless;
use serde_json::json;
fn tmp_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"pc-font-warn-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn missing_font_warnings_emitted_in_headless_and_type_commands() {
let dir = tmp_dir();
let psd_path = dir.join("card.psd");
let png_path = dir.join("card.png");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 600, "height": 300, "background": "white"})).unwrap();
// 1. type.create with missing font
let created = h
.handle("engine.execute", json!({"command": "type.create", "params": {"x": 30, "y": 100, "text": "Headline", "font": "NoSuchFamily123", "size": 40}}))
.unwrap();
assert_eq!(created["warnings"], json!(["font 'NoSuchFamily123' is not installed; text may render using a fallback face"]));
// 2. doc.save card.psd
let saved_psd = h.handle("doc.save", json!({"path": psd_path.to_str().unwrap()})).unwrap();
let warnings = saved_psd["warnings"].as_array().expect("warnings array in doc.save");
assert!(
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
"doc.save PSD warning: {saved_psd}"
);
// 3. doc.save card.png
let saved_png = h.handle("doc.save", json!({"path": png_path.to_str().unwrap()})).unwrap();
let warnings = saved_png["warnings"].as_array().expect("warnings array in doc.save");
assert!(
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
"doc.save PNG warning: {saved_png}"
);
// 4. doc.close then doc.open
h.handle("doc.close", json!({})).unwrap();
let opened = h.handle("doc.open", json!({"path": psd_path.to_str().unwrap()})).unwrap();
let warnings = opened["warnings"].as_array().expect("warnings array in doc.open");
assert!(
warnings.iter().any(|w| w.as_str().unwrap_or("").contains("is not installed; text may render using a fallback face")),
"doc.open warning: {opened}"
);
let _ = std::fs::remove_dir_all(dir);
}
+7
View File
@@ -296,6 +296,13 @@ impl Profile {
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
}
/// Whether colours can be converted into this profile for every intent. Input profiles with
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
/// describe a source but have no PCS → device path.
pub fn can_be_destination(&self) -> bool {
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
}
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
let i = match intent {
Intent::Perceptual => 0,
+68 -1
View File
@@ -450,6 +450,73 @@ fn malformed_profiles_are_rejected_without_panicking() {
}
}
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
// Allow only float rounding at the physical L* limits, not a different printing condition.
let lightness = -1e-4..=100.0 + 1e-4;
paper.iter().chain(&black).all(|v| v.is_finite())
&& lightness.contains(&paper[0])
&& lightness.contains(&black[0])
&& paper[0] > 90.0
&& black[0] < paper[0]
}
#[test]
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
use photocraft_cms::Curve;
use photocraft_cms::pipeline::Stage;
use photocraft_cms::profile::{Lut, LutKind};
let mut p = cmyk().clone();
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
// the inherited coated profile's inverse is not an inverse of this generated model.
p.a2b = [None, None, None];
p.b2a = [None, None, None];
p.a2b[1] = Some(Lut {
kind: LutKind::Ab,
inputs: 4,
outputs: 3,
stages: vec![
Stage::Curves(vec![Curve::Identity; 4]),
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})),
Stage::Curves(vec![Curve::Identity; 3]),
],
});
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
// Explicit expected values, not the validation predicate: also catch wrong channels,
// intent selection, constant output and incorrect LUT encoding/interpolation.
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
}
}
#[test]
fn cmyk_endpoints_reject_invalid_values() {
let paper = [100.0, 0.0, 0.0];
let black = [48.0, 0.0, 0.0];
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for channel in 0..3 {
let mut p = paper;
let mut k = black;
p[channel] = bad;
k[channel] = bad;
assert!(!cmyk_endpoints_valid(p, black));
assert!(!cmyk_endpoints_valid(paper, k));
}
}
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
}
}
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
/// converts through each.
#[test]
@@ -500,7 +567,7 @@ fn system_profiles() {
// Real CMYK profile: paper lighter than 100 % K.
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display());
// And it round-trips through its own BToA.
if p.b2a[1].is_some() {
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
+3 -1
View File
@@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
thiserror.workspace = true
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
png = "0.18"
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
exr = { version = "1.73", default-features = false }
half = "2"
serde_json.workspace = true
@@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon.workspace = true
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
exr = { version = "1.73", default-features = false, features = ["rayon"] }
[dev-dependencies]
proptest.workspace = true
+3
View File
@@ -61,6 +61,9 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
`photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
+1 -1
View File
@@ -16,7 +16,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
photocraft-codecs = { path = "..", features = ["heif"] }
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
heic-rs = "=0.1.1"
heic-rs = "=0.1.4"
[profile.release]
debug = 1
+26 -7
View File
@@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError {
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
/// after checking every part's declared size against the limits, before any pixel allocation.
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
let mut total: u64 = 0;
@@ -93,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize {
best
}
/// Decode the flat layers via the `exr` crate (it handles every flat compression) and
/// return the requested part's layer.
fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let image =
read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?;
image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file")))
/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader`
/// is asked about the headers in file order and the first `Ok` wins, so every header before
/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are
/// then decompressed and only its planes allocated; the other parts are skipped unread.
struct OnlyPart<C> {
inner: C,
index: usize,
seen: std::cell::Cell<usize>,
}
impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
type Reader = C::Reader;
fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result<Self::Reader> {
let position = self.seen.get();
self.seen.set(position.saturating_add(1));
if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) }
}
}
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
}
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
+3 -9
View File
@@ -11,7 +11,6 @@ use std::io::Cursor;
use exr::meta::MetaData;
use exr::meta::attribute::AttributeValue;
use exr::prelude::{ReadChannels, ReadLayers, read};
use crate::Format;
use crate::error::CodecError;
@@ -200,7 +199,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
.iter()
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
.ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?;
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
// Also caps all parts' declared sizes together, like every other flat decode.
let meta = super::exr::read_meta(bytes, limits)?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
}
@@ -214,13 +214,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
limits.check_bytes(w32, h32, per_pixel)?;
let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(
|e| match e {
exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")),
e => CodecError::malformed(F, e),
},
)?;
let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?;
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
+1
View File
@@ -5,6 +5,7 @@ pub(crate) mod fits;
pub(crate) mod gif_frames;
pub(crate) mod heif;
pub(crate) mod jpeg;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
pub(crate) mod tiff;
+118
View File
@@ -0,0 +1,118 @@
//! ZSoft PCX (Paintbrush) reader: strictly bounded scanline RLE, 16/256-colour
//! palettes and 8-bit three-plane RGB. Read-only: modern saves should use PNG.
use crate::SampleType;
use crate::error::CodecError;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
const HEADER: usize = 128;
fn bad(reason: impl std::fmt::Display) -> CodecError {
CodecError::malformed(Format::Pcx, reason)
}
fn le16(b: &[u8], i: usize) -> u16 {
u16::from_le_bytes([b[i], b[i + 1]])
}
/// PCX lacks a strong magic number. Require plausible complete header fields before
/// identifying it, rather than classifying every file starting with 0x0a as PCX.
pub(crate) fn looks_like_pcx(b: &[u8]) -> bool {
if b.len() < HEADER || b[0] != 0x0a || !matches!(b[1], 0 | 2 | 3 | 5) || b[2] != 1 {
return false;
}
let bits = b[3];
let planes = b[65];
let stride = u32::from(le16(b, 66));
let (x0, y0, x1, y1) = (le16(b, 4), le16(b, 6), le16(b, 8), le16(b, 10));
if x1 < x0 || y1 < y0 || !matches!(bits, 1 | 2 | 4 | 8) || !(1..=4).contains(&planes) {
return false;
}
let width = u32::from(x1 - x0) + 1;
let min_stride = (width * u32::from(bits)).div_ceil(8);
stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4)
}
/// Read one compressed plane's row. RLE packets never cross a scanline.
fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> {
let mut n = 0;
while n < out.len() {
let code = *b.get(*pos).ok_or_else(|| bad("truncated pixel RLE"))?;
*pos += 1;
let (count, value) = if code >= 0xc0 {
let count = usize::from(code & 0x3f);
let value = *b.get(*pos).ok_or_else(|| bad("truncated RLE packet"))?;
*pos += 1;
(count, value)
} else {
(1, code)
};
if count == 0 || count > out.len() - n {
return Err(bad("RLE packet crosses a scanline or has zero length"));
}
out[n..n + count].fill(value);
n += count;
}
Ok(())
}
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !looks_like_pcx(b) {
return Err(bad("invalid PCX header or unsupported pixel layout"));
}
let (w, h) = (u32::from(le16(b, 8) - le16(b, 4)) + 1, u32::from(le16(b, 10) - le16(b, 6)) + 1);
limits.check(w, h, ChannelLayout::Rgb, SampleType::U8)?;
let (bits, planes, stride) = (usize::from(b[3]), usize::from(b[65]), usize::from(le16(b, 66)));
let row_len = stride.checked_mul(planes).ok_or_else(|| bad("row size overflow"))?;
let total = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(3)).ok_or_else(|| bad("pixel size overflow"))?;
// The last 769 bytes are the optional VGA palette for 8-bit indexed images.
let vga = bits == 8 && planes == 1 && b.len() >= HEADER + 769 && b[b.len() - 769] == 0x0c;
if bits == 8 && planes == 1 && !vga && le16(b, 68) != 2 {
return Err(bad("8-bit indexed PCX has no 256-colour palette"));
}
let image_end = if vga { b.len() - 769 } else { b.len() };
let mut row = vec![0u8; row_len];
let mut pixels = vec![0u8; total];
let mut p = HEADER;
let width = w as usize;
for y in 0..h as usize {
for plane in row.chunks_mut(stride) {
read_row(&b[..image_end], &mut p, plane)?;
}
for x in 0..width {
let rgb = if bits == 8 && planes == 3 {
[row[x], row[stride + x], row[2 * stride + x]]
} else {
let index = if bits == 8 {
usize::from(row[x])
} else {
let bit = x * bits;
let shift = 8 - bits - (bit % 8);
let mask = (1usize << bits) - 1;
(0..planes).fold(0usize, |v, plane| {
let sample = (usize::from(row[plane * stride + bit / 8]) >> shift) & mask;
v | sample << (plane * bits)
})
};
if vga {
let o = image_end + 1 + index * 3;
[b[o], b[o + 1], b[o + 2]]
} else if bits == 8 {
let g = index as u8;
[g, g, g] // palette-info 2: gray scale
} else {
let o = 16 + index * 3;
[b[o], b[o + 1], b[o + 2]]
}
};
pixels[(y * width + x) * 3..(y * width + x + 1) * 3].copy_from_slice(&rgb);
}
}
let mut image = Image::from_u8(w, h, ChannelLayout::Rgb, pixels)?;
let (xdpi, ydpi) = (le16(b, 12), le16(b, 14));
if xdpi > 0 && ydpi > 0 {
image.meta.dpi = Some((f32::from(xdpi), f32::from(ydpi)));
}
Ok(image)
}
+6 -2
View File
@@ -7,7 +7,7 @@ use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits, PngCompression};
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
const F: Format = Format::Png;
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
@@ -29,6 +29,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let (w, h) = info.size();
// Worst case output: 4 channels x 16 bit.
limits.check_bytes(w, h, 8)?;
// zlib never beats 1032:1; refuse a file too small for the declared image before the
// output buffer exists. Measured on the stored samples (before palette/bit expansion).
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8);
Limits::check_plausible(stored, bytes.len(), 1100)?;
}
let mut reader = decoder.read_info().map_err(map_png_err)?;
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
@@ -47,7 +51,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
d => return Err(err(format!("unexpected output bit depth {d:?}"))),
};
let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = vec![0u8; size];
let mut buf = alloc_zeroed::<u8>(size)?;
let out = reader.next_frame(&mut buf).map_err(map_png_err)?;
buf.truncate(out.buffer_size());
// Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels
+49 -1
View File
@@ -145,6 +145,14 @@ impl<'a> File<'a> {
Some(if self.le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
}
/// The declared entry count of the directory at `at`, before its table is parsed.
pub fn entry_count(&self, at: u64) -> Option<u64> {
if at < self.header_len() {
return None;
}
if self.big { self.u64_at(at) } else { self.u16_at(at).map(u64::from) }
}
/// The directory at `at`: its whole table, up to and including the next-IFD pointer, must
/// be inside the file and after the header.
pub fn ifd(&self, at: u64) -> Option<Ifd> {
@@ -355,6 +363,10 @@ struct Walk<'a, 'f> {
seen: HashSet<u64>,
pages: Vec<TiffPage>,
complete: bool,
/// Directory entries that may still be parsed. Real directory tables are disjoint, so their
/// total size is bounded by the file; overlapping tables (a hostile file) would otherwise
/// make every one of thousands of directories cost a full 65,535-entry parse.
entry_budget: u64,
}
impl Walk<'_, '_> {
@@ -366,6 +378,15 @@ impl Walk<'_, '_> {
self.complete = false;
return;
}
let Some(n) = self.f.entry_count(at) else {
self.complete = false;
return;
};
if n > self.entry_budget {
self.complete = false;
return;
}
self.entry_budget -= n;
let Some(ifd) = self.f.ifd(at) else {
self.complete = false;
return;
@@ -391,10 +412,37 @@ pub fn tiff_info(bytes: &[u8]) -> Result<TiffInfo, CodecError> {
}
pub(crate) fn info_of(f: &File<'_>) -> Result<TiffInfo, CodecError> {
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true };
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true, entry_budget: (f.b.len() as u64) / 12 + 64 };
w.chain(f.first_ifd, None, 0);
if w.pages.is_empty() {
return Err(CodecError::malformed(Format::Tiff, "the first image directory is missing or cut off"));
}
Ok(TiffInfo { big_tiff: f.big, little_endian: f.le, pages: w.pages, complete: w.complete })
}
#[cfg(test)]
mod tests {
use super::*;
/// 10,000 directories 4 bytes apart, each declaring 65,535 overlapping entries: the walk must
/// stop after the file-size entry budget instead of parsing ~6.5e8 entries.
#[test]
fn overlapping_directory_tables_exhaust_the_entry_budget() {
let first = 8usize;
let table = 2 + 65_535 * 12 + 4;
let mut b = vec![0u8; first + table + 4 * 10_000];
b[..8].copy_from_slice(&[b'I', b'I', 42, 0, first as u8, 0, 0, 0]);
for i in 0..10_000usize {
let at = first + 4 * i;
b[at..at + 2].copy_from_slice(&u16::MAX.to_le_bytes());
let next_cell = at + 2 + 65_535 * 12;
let next = (first + 4 * (i + 1)) as u32;
if let Some(cell) = b.get_mut(next_cell..next_cell + 4) {
cell.copy_from_slice(&next.to_le_bytes());
}
}
let info = tiff_info(&b).expect("the first directory is readable");
assert!(!info.complete);
assert!(info.pages.len() < 200, "{} directories parsed", info.pages.len());
}
}
+5
View File
@@ -32,6 +32,11 @@ 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()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
+5 -2
View File
@@ -9,7 +9,7 @@ use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits};
use crate::options::{EncodeOptions, Limits, alloc_zeroed};
const F: Format = Format::WebP;
@@ -23,8 +23,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let (w, h) = dec.dimensions();
let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
limits.check(w, h, layout, SampleType::U8)?;
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = vec![0u8; size];
let mut buf = alloc_zeroed::<u8>(size)?;
dec.read_image(&mut buf).map_err(err)?;
let icc = dec.icc_profile().ok().flatten();
let exif = dec.exif_metadata().ok().flatten();
+15 -1
View File
@@ -15,6 +15,8 @@ pub enum Format {
Ico,
/// Netpbm family: PBM/PGM/PPM (P1–P6), PAM (P7) and PFM (`PF`/`Pf`).
Pnm,
/// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB.
Pcx,
Qoi,
OpenExr,
/// Radiance RGBE.
@@ -56,6 +58,10 @@ pub struct FormatCaps {
/// Formats that are enabled but intentionally not symmetric, with the reason.
/// The test-suite asserts that every other enabled format is read+write.
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
(
Format::Pcx,
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
),
(
Format::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
@@ -78,7 +84,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 15] = [
pub const ALL: [Format; 16] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -88,6 +94,7 @@ impl Format {
Format::Tga,
Format::Ico,
Format::Pnm,
Format::Pcx,
Format::Qoi,
Format::OpenExr,
Format::Hdr,
@@ -117,6 +124,7 @@ impl Format {
Format::Tga => "TGA",
Format::Ico => "ICO",
Format::Pnm => "Netpbm",
Format::Pcx => "PCX",
Format::Qoi => "QOI",
Format::OpenExr => "OpenEXR",
Format::Hdr => "Radiance HDR",
@@ -139,6 +147,7 @@ impl Format {
Format::Tga => &["tga", "icb", "vda", "vst"],
Format::Ico => &["ico"],
Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"],
Format::Pcx => &["pcx"],
Format::Qoi => &["qoi"],
Format::OpenExr => &["exr"],
Format::Hdr => &["hdr"],
@@ -159,6 +168,7 @@ impl Format {
Format::Tga => "image/x-tga",
Format::Ico => "image/x-icon",
Format::Pnm => "image/x-portable-anymap",
Format::Pcx => "image/x-pcx",
Format::Qoi => "image/qoi",
Format::OpenExr => "image/x-exr",
Format::Hdr => "image/vnd.radiance",
@@ -207,6 +217,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base },
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
Format::Qoi => base,
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
@@ -266,6 +277,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(b"SIMPLE =") {
return Some(Format::Fits);
}
if crate::codecs::pcx::looks_like_pcx(b) {
return Some(Format::Pcx);
}
if looks_like_tga(b) {
return Some(Format::Tga);
}
+111
View File
@@ -512,6 +512,8 @@ impl Image {
out.extend_from_slice(&one);
}
out
} else if let Some(fast) = self.convert_int_direct(layout, sample) {
fast
} else if layout == self.layout {
quantize(&self.to_normalized(), sample)
} else {
@@ -531,6 +533,55 @@ impl Image {
}
}
/// Integer fast path of [`Image::convert`] for U8/U16 sources and targets whose result is
/// a pure channel copy: same colour model (alpha added as opaque, or dropped) or gray to
/// RGB. Bit-identical to the generic f32 route (see the equivalence tests) but without
/// the two intermediate f32 buffers. Returns `None` for everything else.
fn convert_int_direct(&self, layout: ChannelLayout, sample: SampleType) -> Option<Vec<u8>> {
let int = |s: SampleType| matches!(s, SampleType::U8 | SampleType::U16);
if !int(self.sample) || !int(sample) {
return None;
}
let gray_to_rgb = self.layout.is_gray() && layout.is_rgb();
if !gray_to_rgb && !layout.same_model(self.layout) {
return None;
}
let one: &[u8] = match sample {
SampleType::U8 => &[u8::MAX],
_ => &u16::MAX.to_ne_bytes(),
};
let (fc, tc) = (self.layout.channels(), layout.channels());
let (sb, db) = (self.sample.bytes(), sample.bytes());
let (src_color, dst_color) = (self.layout.color_channels(), layout.color_channels());
let n = self.data.len() / (fc * sb);
let mut out = Vec::with_capacity(n.checked_mul(tc)?.checked_mul(db)?);
macro_rules! run {
($conv:expr) => {{
let conv = $conv;
for px in self.data.chunks_exact(fc * sb) {
for j in 0..dst_color {
let c = if src_color == 1 { 0 } else { j };
conv(&px[c * sb..(c + 1) * sb], &mut out);
}
if layout.has_alpha() {
if self.layout.has_alpha() {
conv(&px[src_color * sb..(src_color + 1) * sb], &mut out);
} else {
out.extend_from_slice(one);
}
}
}
}};
}
match (self.sample, sample) {
(SampleType::U8, SampleType::U8) => run!(|s: &[u8], o: &mut Vec<u8>| o.push(s[0])),
(SampleType::U16, SampleType::U16) => run!(|s: &[u8], o: &mut Vec<u8>| o.extend_from_slice(s)),
(SampleType::U8, SampleType::U16) => run!(|s: &[u8], o: &mut Vec<u8>| o.extend_from_slice(&(u16::from(s[0]) * 257).to_ne_bytes())),
_ => run!(|s: &[u8], o: &mut Vec<u8>| o.push(((u32::from(u16::from_ne_bytes([s[0], s[1]])) + 128) / 257) as u8)),
}
Some(out)
}
/// [`Image::convert`], borrowing `self` when it already has that layout and sample type
/// (encoders use it so a full-size image isn't copied for nothing).
pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> {
@@ -661,6 +712,66 @@ mod tests {
use super::*;
/// The generic f32 route `convert` used before the integer fast paths (the reference).
fn convert_via_f32(img: &Image, layout: ChannelLayout, sample: SampleType) -> Vec<u8> {
quantize(&convert_layout(&img.to_normalized(), img.layout, layout), sample)
}
#[test]
fn int_fast_path_matches_f32_route_for_every_value() {
// Every U8 and every U16 value through every depth pair (alpha goes through the same per-sample conversion; covered by the layout test).
let all8: Vec<u8> = (0..=255).collect();
let all16: Vec<u16> = (0..=65535).collect();
for (src, to) in
[(SampleType::U8, SampleType::U8), (SampleType::U8, SampleType::U16), (SampleType::U16, SampleType::U8), (SampleType::U16, SampleType::U16)]
{
let img = if src == SampleType::U8 {
Image::from_u8(256, 1, ChannelLayout::Gray, all8.clone()).unwrap()
} else {
Image::from_u16(65536, 1, ChannelLayout::Gray, &all16).unwrap()
};
for layout in [ChannelLayout::Gray, ChannelLayout::Rgb] {
assert_eq!(img.convert(layout, to).data(), &convert_via_f32(&img, layout, to)[..], "{src:?}->{to:?} {layout:?}");
}
}
}
#[test]
fn int_fast_path_matches_f32_route_for_every_layout() {
let mut seed = 0x9e3779b97f4a7c15u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for from_s in [SampleType::U8, SampleType::U16] {
for from_l in ChannelLayout::ALL {
let n = 64 * from_l.channels();
let mut vals = Vec::new();
for i in 0..n {
// Edge values (0, 1, max-1, max, mid) mixed with random ones.
let r = next();
let v = match i % 7 {
0 => 0.0,
1 => 1.0,
2 => 1.0 / 255.0,
3 => 254.0 / 255.0,
_ => (r % 65536) as f32 / 65535.0,
};
vals.push(v);
}
let img = Image::from_normalized(64, 1, from_l, from_s, &vals).unwrap();
for to_l in ChannelLayout::ALL {
for to_s in SampleType::ALL {
let got = img.convert(to_l, to_s);
assert_eq!(got.data(), &convert_via_f32(&img, to_l, to_s)[..], "{from_l:?}/{from_s:?} -> {to_l:?}/{to_s:?}");
}
}
}
}
}
#[test]
fn u8_u16_roundtrip_exact() {
let v: Vec<u8> = (0..=255).collect();
+10 -1
View File
@@ -22,6 +22,7 @@ mod image;
mod options;
pub mod orientation;
pub mod resolution;
pub mod source_settings;
pub mod web;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
@@ -39,9 +40,10 @@ pub use crate::image::{
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
pub use half::f16;
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -72,6 +74,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Tiff => tiff::decode(bytes, l),
Format::WebP => webp::decode(bytes, l),
Format::Pnm => pnm::decode(bytes, l),
Format::Pcx => pcx::decode(bytes, l),
Format::Fits => fits::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
@@ -95,6 +98,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
finish_decode(img, o, opts)
}
/// Returns the embedded colour table for an indexed BMP, if it has one.
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
via_image::bmp_palette(bytes)
}
/// 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.
@@ -214,6 +222,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Tiff => tiff::encode(image, plan, opts),
Format::WebP => webp::encode(image, plan, opts),
Format::Pnm => pnm::encode(image, plan, opts),
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
Format::Fits => fits::encode(image, plan, opts),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
+27 -2
View File
@@ -21,8 +21,10 @@ pub struct Limits {
impl Default for Limits {
fn default() -> Self {
// 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB).
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). On 32-bit
// targets (wasm32 has a 4 GiB address space shared with the document, history and
// the decoder's own buffers) 1 GiB is the most one buffer can safely ask for.
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 };
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc }
}
}
@@ -60,6 +62,19 @@ impl Limits {
Ok(())
}
/// Decompression-bomb guard for formats that compress: refuse a file too small to hold
/// `declared_bytes` of (stored, pre-expansion) image data, given that the compression
/// never beats `max_ratio`:1. Called before the output buffer is allocated, so a
/// header-only hostile file cannot make the decoder reserve gigabytes. A fixed 1 MiB of
/// slack keeps tiny images and headers with large metadata unaffected.
pub(crate) fn check_plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> Result<(), CodecError> {
let capacity = (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20);
if declared_bytes > capacity {
return Err(CodecError::LimitExceeded(format!("a {file_len} byte file cannot hold {declared_bytes} bytes of image data")));
}
Ok(())
}
pub(crate) fn alloc_usize(&self) -> usize {
usize::try_from(self.max_alloc).unwrap_or(usize::MAX)
}
@@ -149,3 +164,13 @@ impl Default for EncodeOptions {
}
}
}
/// A zero-filled buffer of `len` elements, allocated fallibly: an allocation failure becomes
/// [`CodecError::LimitExceeded`] instead of aborting the process (which `catch_unwind` cannot
/// stop).
pub(crate) fn alloc_zeroed<T: Copy + Default>(len: usize) -> Result<Vec<T>, CodecError> {
let mut v = Vec::new();
v.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} elements for the image")))?;
v.resize(len, T::default());
Ok(v)
}
+355
View File
@@ -0,0 +1,355 @@
//! The encoder settings a lossy file was written with, read back from its own data, so a save
//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or
//! lossless encoding).
//!
//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale
//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the
//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's
//! best is the one it was saved at (exact for such tables; the closest fit for others).
//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index
//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses.
//!
//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`.
use crate::codecs::vp8;
/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order.
const ANNEX_K_LUMA: [u16; 64] = [
16, 11, 10, 16, 24, 40, 51, 61, //
12, 12, 14, 19, 26, 58, 60, 55, //
14, 13, 16, 24, 40, 57, 69, 56, //
14, 17, 22, 29, 51, 87, 80, 62, //
18, 22, 37, 56, 68, 109, 103, 77, //
24, 35, 55, 64, 81, 104, 113, 92, //
49, 64, 78, 87, 103, 121, 120, 101, //
72, 92, 95, 98, 112, 100, 103, 99,
];
/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order.
const ANNEX_K_CHROMA: [u16; 64] = [
17, 18, 24, 47, 99, 99, 99, 99, //
18, 21, 26, 66, 99, 99, 99, 99, //
24, 26, 56, 99, 99, 99, 99, 99, //
47, 66, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99, //
99, 99, 99, 99, 99, 99, 99, 99,
];
/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT
/// stores its 64 values in this order.
const ZIGZAG: [usize; 64] = [
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
];
/// One quantisation table from a DQT segment, in natural order, with its precision.
#[derive(Debug, Clone, Copy)]
struct Table {
values: [u16; 64],
/// 16-bit entries (Pq = 1) rather than 8-bit.
wide: bool,
}
/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a
/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable
/// luminance table (or isn't a JPEG).
pub fn jpeg_quality(bytes: &[u8]) -> Option<u8> {
let tables = jpeg_tables(bytes);
let [luma, chroma, ..] = tables;
let luma = luma?;
let mut best: Option<(u64, u8)> = None;
for q in 1..=100u8 {
let mut err = table_error(&luma, &ANNEX_K_LUMA, q);
if let Some(c) = chroma {
err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q));
}
if best.is_none_or(|(e, _)| err < e) {
best = Some((err, q));
}
}
best.map(|(_, q)| q)
}
/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and
/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below
/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767.
fn scaled(base: u16, q: u8, wide: bool) -> u32 {
let q = u32::from(q.clamp(1, 100));
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
let v = (u32::from(base) * scale + 50) / 100;
v.clamp(1, if wide { 32767 } else { 255 })
}
/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference.
fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 {
t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum()
}
/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same
/// slot replaces an earlier one).
fn jpeg_tables(b: &[u8]) -> [Option<Table>; 4] {
let mut tables = [None; 4];
if b.get(..2) != Some(&[0xFF, 0xD8]) {
return tables;
}
let mut i = 2usize;
while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) {
if marker == 0xFF {
i = i.saturating_add(1);
continue;
}
if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
i = i.saturating_add(2);
continue;
}
if marker == 0xDA || marker == 0xD9 {
break;
}
let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break };
let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break };
if marker == 0xDB {
read_dqt(seg, &mut tables);
}
i = i.saturating_add(2).saturating_add(len);
}
tables
}
/// The tables of one DQT segment; a truncated or malformed table ends the segment.
fn read_dqt(mut seg: &[u8], tables: &mut [Option<Table>; 4]) {
while let Some((&pq_tq, rest)) = seg.split_first() {
let wide = pq_tq >> 4 == 1;
if pq_tq >> 4 > 1 {
return;
}
let slot = usize::from(pq_tq & 0x0F);
let n = if wide { 128 } else { 64 };
let Some(data) = rest.get(..n) else { return };
let mut values = [0u16; 64];
for (k, &natural) in ZIGZAG.iter().enumerate() {
let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) };
if let Some(dst) = values.get_mut(natural) {
*dst = v;
}
}
if let Some(dst) = tables.get_mut(slot) {
*dst = Some(Table { values, wide });
}
seg = rest.get(n..).unwrap_or(&[]);
}
}
/// How a WebP file's image is compressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebpEncoding {
/// VP8L.
Lossless,
/// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the
/// frame header could be read.
Lossy { quality: Option<u8> },
}
/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no
/// image chunk.
pub fn webp_encoding(bytes: &[u8]) -> Option<WebpEncoding> {
if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") {
return None;
}
find_image_chunk(bytes.get(12..)?, 0)
}
/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames.
fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option<WebpEncoding> {
while let Some(head) = chunks.get(..8) {
let fourcc = head.get(..4)?;
let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?;
let end = 8usize.checked_add(size)?;
// A truncated last chunk still holds its header.
let payload = chunks.get(8..end.min(chunks.len()))?;
match fourcc {
b"VP8L" => return Some(WebpEncoding::Lossless),
b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }),
// An animation frame: a 16-byte header, then the frame's own chunks.
b"ANMF" if depth == 0 => {
if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) {
return Some(found);
}
}
_ => {}
}
chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]);
}
None
}
/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the
/// quality whose index is nearest; the middle one when several share it).
fn vp8_quality(frame: &[u8]) -> Option<u8> {
let qi = vp8_base_quantizer(frame)?;
let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi);
let best = (0..=100u8).map(distance).min()?;
let matches: Vec<u8> = (0..=100u8).filter(|&q| distance(q) == best).collect();
let mid = matches.get(matches.len() / 2)?;
Some(*mid)
}
/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386
/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or
/// adjusted by their mean delta otherwise.
fn vp8_base_quantizer(frame: &[u8]) -> Option<u8> {
let tag = frame.get(..3)?;
// Bit 0 clear: a key frame, which carries the start code and dimensions.
if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] {
return None;
}
let mut d = BoolDecoder::new(frame.get(10..)?);
d.literal(1); // color_space
d.literal(1); // clamping_type
let mut segments: Option<(bool, [i32; 4])> = None;
if d.flag() {
let update_map = d.flag();
if d.flag() {
let absolute = d.flag();
let mut q = [0i32; 4];
for v in &mut q {
*v = if d.flag() { d.signed(7) } else { 0 };
}
for _ in 0..4 {
if d.flag() {
d.signed(6);
}
}
segments = Some((absolute, q));
}
if update_map {
for _ in 0..3 {
if d.flag() {
d.literal(8);
}
}
}
}
d.literal(1); // filter_type
d.literal(6); // loop_filter_level
d.literal(3); // sharpness_level
if d.flag() && d.flag() {
// loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas.
for _ in 0..8 {
if d.flag() {
d.signed(6);
}
}
}
d.literal(2); // log2_nbr_of_dct_partitions
if d.exhausted() {
return None;
}
let base = d.literal(7) as i32;
let qi = match segments {
Some((true, q)) => q.iter().sum::<i32>() / 4,
Some((false, q)) => base + q.iter().sum::<i32>() / 4,
None => base,
};
u8::try_from(qi.clamp(0, 127)).ok()
}
/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data.
struct BoolDecoder<'a> {
data: &'a [u8],
pos: usize,
value: u32,
range: u32,
bit_count: u32,
/// Bytes read past the end of the data.
overrun: usize,
}
impl<'a> BoolDecoder<'a> {
fn new(data: &'a [u8]) -> Self {
let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 };
d.value = (d.next_byte() << 8) | d.next_byte();
d
}
fn next_byte(&mut self) -> u32 {
match self.data.get(self.pos) {
Some(&b) => {
self.pos += 1;
u32::from(b)
}
None => {
self.overrun = self.overrun.saturating_add(1);
0
}
}
}
/// The header was read from beyond the end of the data (a couple of bytes of look-ahead
/// are normal).
fn exhausted(&self) -> bool {
self.overrun > 2
}
fn bool(&mut self, prob: u32) -> bool {
let split = 1 + (((self.range - 1) * prob) >> 8);
let big_split = split << 8;
let bit = if self.value >= big_split {
self.range -= split;
self.value -= big_split;
true
} else {
self.range = split;
false
};
while self.range < 128 {
self.value <<= 1;
self.range <<= 1;
self.bit_count += 1;
if self.bit_count == 8 {
self.bit_count = 0;
self.value |= self.next_byte();
}
}
bit
}
fn flag(&mut self) -> bool {
self.bool(128)
}
fn literal(&mut self, bits: u32) -> u32 {
(0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag()))
}
/// A magnitude of `bits` bits followed by a sign bit.
fn signed(&mut self, bits: u32) -> i32 {
let v = self.literal(bits) as i32;
if self.flag() { -v } else { v }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaling_matches_the_published_curve() {
// Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it.
assert_eq!(scaled(16, 50, false), 16);
assert_eq!(scaled(121, 100, false), 1);
assert_eq!(scaled(16, 75, false), 8);
assert_eq!(scaled(121, 1, false), 255);
assert_eq!(scaled(121, 1, true), 6050);
}
#[test]
fn zigzag_is_a_permutation() {
let mut seen = [false; 64];
for &n in &ZIGZAG {
seen[n] = true;
}
assert!(seen.iter().all(|&s| s));
}
}
+29
View File
@@ -197,3 +197,32 @@ fn parts_are_capped_together() {
let e = exr_info(&aov_file(), &limits).unwrap_err().to_string();
assert!(e.contains("across 3 parts"), "{e}");
}
/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so
/// inflating it fails.
fn file_with_corrupt_first_part() -> Vec<u8> {
let px = |k: f32| -> Vec<(&'static str, Vec<f32>)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() };
let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]);
let mut cursor = std::io::Cursor::new(&bytes[..]);
exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap();
let table = cursor.position() as usize;
// Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block).
for i in 0..4 {
let at = table + i * 8;
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
bytes[offset + 12..offset + 12 + size].fill(0xA5);
}
bytes
}
#[test]
fn decoding_one_part_does_not_decode_the_others() {
let bytes = file_with_corrupt_first_part();
// Part 0 is unreadable ...
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
// ... but asking for part 1 must not touch it.
let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own");
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}
+24
View File
@@ -93,6 +93,30 @@ fn png_bomb_header_rejected_before_alloc() {
assert!(is_limit(decode(&b)));
}
#[test]
fn png_header_at_the_default_limit_needs_a_plausible_file_size() {
// 32768 x 32768 x RGBA16 is exactly the default max_alloc: it passes the dimension limits,
// but a ~100 byte file cannot hold it (zlib tops out near 1032:1), so it is refused before
// the 8 GiB output buffer is allocated.
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&32768u32.to_be_bytes());
ihdr.extend_from_slice(&32768u32.to_be_bytes());
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
b.extend(png_chunk(b"IHDR", &ihdr));
b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]));
b.extend(png_chunk(b"IEND", &[]));
assert!(is_limit(decode(&b)));
}
#[test]
fn png_highly_compressible_image_still_decodes() {
let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap();
let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert!(b.len() < 100_000, "{}", b.len());
assert_eq!(decode(&b).unwrap().width(), 3000);
}
const PNG_TEXT_BUDGET: usize = 64 << 10;
fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result<Image, CodecError> {
+100
View File
@@ -0,0 +1,100 @@
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, caps, decode, decode_as, detect, encode, from_extension};
fn header(bits: u8, planes: u8, width: u16, height: u16, stride: u16) -> Vec<u8> {
let mut b = vec![0u8; 128];
b[0] = 0x0a; // ZSoft
b[1] = 5; // PC Paintbrush 3.0+
b[2] = 1; // RLE
b[3] = bits;
b[8..10].copy_from_slice(&(width - 1).to_le_bytes());
b[10..12].copy_from_slice(&(height - 1).to_le_bytes());
b[12..14].copy_from_slice(&300u16.to_le_bytes());
b[14..16].copy_from_slice(&150u16.to_le_bytes());
b[65] = planes;
b[66..68].copy_from_slice(&stride.to_le_bytes());
b[68..70].copy_from_slice(&1u16.to_le_bytes());
b
}
fn rle_row(out: &mut Vec<u8>, row: &[u8]) {
for &value in row {
if value >= 0xc0 {
out.extend_from_slice(&[0xc1, value]);
} else {
out.push(value);
}
}
}
#[test]
fn pcx_is_readable_but_not_writable() {
assert_eq!(from_extension("archive.PCX"), Some(Format::Pcx));
assert!(caps(Format::Pcx).read && !caps(Format::Pcx).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgb, vec![0, 0, 0]).unwrap();
assert!(matches!(encode(&img, Format::Pcx, &Default::default()), Err(CodecError::Unsupported { .. })));
}
#[test]
fn decodes_three_plane_rgb_and_keeps_dpi() {
let mut b = header(8, 3, 2, 1, 2);
for row in [&[255, 0][..], &[0, 255][..], &[0, 0][..]] {
rle_row(&mut b, row);
}
assert_eq!(detect(&b), Some(Format::Pcx));
let im = decode(&b).unwrap();
assert_eq!(im.layout(), ChannelLayout::Rgb);
assert_eq!(im.sample_type(), SampleType::U8);
assert_eq!(im.dimensions(), (2, 1));
assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]);
assert_eq!(im.meta.dpi, Some((300.0, 150.0)));
}
#[test]
fn decodes_256_colour_vga_palette() {
let mut b = header(8, 1, 2, 1, 2);
rle_row(&mut b, &[1, 2]);
b.push(0x0c);
let mut palette = vec![0; 256 * 3];
palette[3..6].copy_from_slice(&[255, 0, 0]);
palette[6..9].copy_from_slice(&[0, 255, 0]);
b.extend(palette);
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]);
}
#[test]
fn decodes_4bit_and_monochrome_palettes() {
let mut four = header(4, 1, 2, 1, 2);
four[16 + 3..16 + 6].copy_from_slice(&[255, 0, 0]);
four[16 + 6..16 + 9].copy_from_slice(&[0, 0, 255]);
rle_row(&mut four, &[0x12, 0]);
assert_eq!(decode(&four).unwrap().data(), &[255, 0, 0, 0, 0, 255]);
let mut one = header(1, 1, 2, 1, 2);
one[16 + 3..16 + 6].copy_from_slice(&[255, 0, 0]);
rle_row(&mut one, &[0x80, 0]);
assert_eq!(decode(&one).unwrap().data(), &[255, 0, 0, 0, 0, 0]);
}
#[test]
fn rejects_truncated_and_overshooting_rle_without_panicking() {
let mut b = header(8, 3, 2, 1, 2);
assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated");
b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row
assert!(decode_as(Format::Pcx, &b).is_err());
let mut b = header(8, 3, 2, 1, 2);
b.extend([0xc1]); // missing run value
assert!(decode_as(Format::Pcx, &b).is_err());
let mut b = header(8, 3, 2, 1, 2);
b[66..68].copy_from_slice(&0u16.to_le_bytes());
assert_eq!(detect(&b), None, "invalid stride is not a PCX signature");
}
#[test]
fn refuses_headers_that_exceed_allocation_budget() {
let mut b = header(8, 3, 3000, 3000, 3000);
// The default decoder rejects the allocation before reading any scanline.
let opts = photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() };
assert!(matches!(photocraft_codecs::decode_as_with(Format::Pcx, &b, &opts), Err(CodecError::LimitExceeded(_))));
b[2] = 0;
assert_eq!(detect(&b), None, "unsupported uncompressed encoding");
}
+111
View File
@@ -0,0 +1,111 @@
//! Reading back the settings a lossy file was saved with: a JPEG's quality from its
//! quantisation tables, a WebP's lossy or lossless encoding (and its quality).
mod common;
use common::*;
use photocraft_codecs::*;
fn photo(w: u32, h: u32, layout: ChannelLayout) -> Image {
synth(w, h, layout, SampleType::U8, 5, 0.03)
}
#[test]
fn jpeg_quality_is_estimated_within_two() {
for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] {
let img = photo(40, 24, layout);
for q in [1u8, 5, 10, 25, 40, 50, 60, 70, 75, 80, 85, 90, 95, 98, 100] {
let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap();
let est = jpeg_quality(&bytes).unwrap_or_else(|| panic!("{layout:?} q{q}: no estimate"));
assert!(est.abs_diff(q) <= 2, "{layout:?}: saved at {q}, estimated {est}");
}
}
}
#[test]
fn jpeg_quality_of_hand_built_tables() {
// A minimal JPEG prefix: SOI, one DQT holding the Annex K luminance table (quality 50) in
// zig-zag order, then SOS.
let luma: [u8; 64] = [
16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 22, 22, 24, 49, 35, 37, 29, 40, 58, 51, 61, 60, 57, 51, 56, 55, 64, 72, 92, 78,
64, 68, 87, 69, 55, 56, 80, 109, 81, 87, 95, 98, 103, 104, 103, 62, 77, 113, 121, 112, 100, 120, 92, 101, 103, 99,
];
let mut b = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00];
b.extend_from_slice(&luma);
b.extend_from_slice(&[0xFF, 0xDA, 0, 2]);
assert_eq!(jpeg_quality(&b), Some(50));
}
#[test]
fn hostile_jpeg_tables_give_no_estimate_and_never_panic() {
let img = photo(16, 16, ChannelLayout::Rgb);
let good = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap();
// Every truncation of a real file.
for n in 0..good.len().min(800) {
let _ = jpeg_quality(&good[..n]);
}
let cases: Vec<Vec<u8>> = vec![
vec![],
vec![0xFF],
vec![0xFF, 0xD8],
// A DQT whose length runs past the end.
vec![0xFF, 0xD8, 0xFF, 0xDB, 0xFF, 0xFF, 0x00, 1, 2, 3],
// A DQT of length 0 and 1 (less than its own length field).
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00],
vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01],
// A table cut short, a bad precision, a slot past 3, a 16-bit table cut mid-entry.
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 12, 0x00], vec![9; 9]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x70], vec![9; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x0F], vec![9; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 68, 0x10], vec![0xFF; 65]].concat(),
// All-zero and all-0xFF tables.
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00], vec![0; 64]].concat(),
[vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 131, 0x10], vec![0xFF; 128]].concat(),
vec![0xFF; 64],
];
for c in &cases {
let _ = jpeg_quality(c);
}
assert_eq!(jpeg_quality(&[0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00]), None);
assert_eq!(jpeg_quality(b"not a jpeg at all"), None);
let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert_eq!(jpeg_quality(&png), None);
}
#[test]
fn webp_lossless_and_lossy_are_told_apart() {
let img = photo(33, 21, ChannelLayout::Rgb);
let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
assert_eq!(webp_encoding(&lossless), Some(WebpEncoding::Lossless));
for q in [10u8, 30, 50, 75, 80, 90, 100] {
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }).unwrap();
let Some(WebpEncoding::Lossy { quality: Some(est) }) = webp_encoding(&bytes) else { panic!("q{q}: {:?}", webp_encoding(&bytes)) };
assert!(est.abs_diff(q) <= 2, "lossy WebP saved at {q}, estimated {est}");
}
// With alpha and metadata (the extended container).
let rgba = photo(20, 20, ChannelLayout::Rgba);
let bytes = encode(&rgba, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap();
assert!(matches!(webp_encoding(&bytes), Some(WebpEncoding::Lossy { quality: Some(58..=62) })), "{:?}", webp_encoding(&bytes));
}
#[test]
fn hostile_webp_never_panics() {
let img = photo(24, 24, ChannelLayout::Rgb);
for opts in [EncodeOptions::default(), EncodeOptions { webp_lossless: false, ..Default::default() }] {
let good = encode(&img, Format::WebP, &opts).unwrap();
for n in 0..good.len().min(400) {
let _ = webp_encoding(&good[..n]);
}
let mut flipped = good.clone();
for i in 12..flipped.len().min(64) {
flipped[i] ^= 0xA5;
let _ = webp_encoding(&flipped);
}
}
let mut huge = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
huge.extend_from_slice(&u32::MAX.to_le_bytes());
let _ = webp_encoding(&huge);
let mut anmf = b"RIFF\0\0\0\0WEBPANMF".to_vec();
anmf.extend_from_slice(&3u32.to_le_bytes());
let _ = webp_encoding(&anmf);
assert_eq!(webp_encoding(b"RIFF\0\0\0\0WAVE"), None);
}
+55
View File
@@ -1,3 +1,6 @@
mod common;
use common::tiffgen::*;
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
fn classic_white_is_zero() -> Vec<u8> {
@@ -95,3 +98,55 @@ fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() {
assert_eq!(image.data(), &upright, "orientation {orientation}");
}
}
fn with_compression(mut dir: Dir, code: u64) -> Dir {
for entry in &mut dir.entries {
if entry.0 == 259 {
entry.2 = vec![V::N(code)];
}
}
dir
}
#[test]
fn jpeg_compressed_tiff_decodes() {
// A flat mid-grey 16x16 image as a baseline JPEG stored in one strip (Compression = 7).
let mut jpeg = Vec::new();
let gray = vec![100u8; 16 * 16];
jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&gray, 16, 16, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip");
let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7);
dir.chunks = vec![jpeg];
let bytes = build(true, false, &[dir], &[0]).bytes;
let image = decode(&bytes).expect("a JPEG-compressed TIFF should decode");
assert_eq!(image.dimensions(), (16, 16));
assert!(image.data().iter().all(|&v| v.abs_diff(100) <= 4), "{:?}", image.data().get(..4));
}
#[test]
fn ccitt_group4_tiff_decodes() {
// 8x3 bilevel: a white row, then two black rows. Bits: V0 | H(white 0, black 8) | V0 V0 | EOFB.
let bits = "1".to_string() + "001" + "00110101" + "000101" + "11" + "000000000001" + "000000000001";
let mut data = Vec::new();
for chunk in bits.as_bytes().chunks(8) {
let mut byte = 0u8;
for (i, c) in chunk.iter().enumerate() {
if *c == b'1' {
byte |= 0x80 >> i;
}
}
data.push(byte);
}
let mut dir = with_compression(strips(8, 3, 1, 1, 0, 3, &[0; 3]), 4);
dir.chunks = vec![data];
let bytes = build(true, false, &[dir], &[0]).bytes;
let image = decode(&bytes).expect("a CCITT G4 TIFF should decode");
assert_eq!(image.dimensions(), (8, 3));
let d = image.data();
assert_eq!(d.len(), 24, "{d:?}");
assert!(d[..8].iter().all(|&v| v == d[0]), "{d:?}");
assert!(d[8..].iter().all(|&v| v == d[8]), "{d:?}");
assert_ne!(d[0], d[8], "{d:?}");
assert_eq!(d[0], 255, "white row of a WhiteIsZero image");
}
+339 -30
View File
@@ -3,8 +3,8 @@
//! Formulas are documented approximations of Photoshop behaviour. Exact matching is tuned in
//! milestone M7 against Photoshop-rendered PSD composites (the oracle in `testkit`).
use photocraft_color::SampleType;
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, mat3_mul, rgb_to_gray, srgb_to_linear};
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, lab_to_srgb, mat3_mul, rgb_to_gray, srgb_to_lab, srgb_to_linear};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Adjustment;
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace, lut3d_len};
@@ -45,14 +45,16 @@ impl Transfer {
t => t,
}
}
fn decode(self, v: f32) -> f32 {
/// Tone-encoded value → linear light.
pub fn decode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
Transfer::Gamma(1.0) => v.max(0.0),
Transfer::Gamma(g) => v.max(0.0).powf(g),
}
}
fn encode(self, v: f32) -> f32 {
/// Linear light → tone-encoded value.
pub fn encode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::linear_to_srgb(v.max(0.0)),
Transfer::Gamma(1.0) => v.max(0.0),
@@ -92,11 +94,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
let b = brightness / 255.0;
let c = contrast.clamp(-100.0, 99.0);
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
map_rgb(buf, |px| px.map(|v| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)))
let f = legacy_brightness_contrast(*brightness, *contrast);
map_rgb(buf, |px| px.map(&f))
}
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
@@ -166,7 +165,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
None => (0.0, 0.0, 0.0),
};
hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0 + dl).clamp(-1.0, 1.0), *colorize)
let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize);
range_lightness(out, dl)
})
}
Adjustment::Vibrance { vibrance, saturation } => {
@@ -191,7 +191,9 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| {
let g = black_white_gray(c, weights);
match tint {
Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5),
// Photoshop tints like the Color blend mode: the tint's hue and saturation at the
// grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11.
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
None => [g; 3],
}
}),
@@ -236,6 +238,99 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
}
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
let b = brightness / 255.0;
let c = contrast.clamp(-100.0, 99.0);
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)
}
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
/// documents the adjustments Photoshop evaluates on the L, a and b channels do so (see
/// [`lab_channel_adjustment`]).
pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
if mode == ColorMode::Lab && lab_channel_adjustment(adj) {
apply_lab(adj, buf);
} else {
apply_depth(adj, buf, transfer, depth);
}
}
/// Whether a Lab document evaluates `adj` on its L, a and b channels rather than on the screen
/// colour: Invert and Posterize (L, a and b alike), Brightness/Contrast (L only), Threshold
/// (split on L, neutral result) and Gradient Map (driven by L). Levels and Curves carry their own
/// Lab records ([`ToneSpace::Lab`]); everything else stays on the screen colour.
pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
matches!(
adj,
Adjustment::Invert
| Adjustment::Posterize { .. }
| Adjustment::Threshold { .. }
| Adjustment::BrightnessContrast { .. }
| Adjustment::GradientMap { .. }
)
}
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
/// (b+128)/255.
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
let l = srgb_to_lab(c);
[l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0]
}
/// The inverse of [`lab_norm`].
fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
}
/// The Lab evaluation of a [`lab_channel_adjustment`] (no-op for any other adjustment).
fn apply_lab(adj: &Adjustment, buf: &mut Buffer) {
match adj {
// n → 1 - n on every channel: L → 100 - L and a, b mirrored about neutral.
Adjustment::Invert => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| 1.0 - v.clamp(0.0, 1.0)))),
Adjustment::Posterize { levels } => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| posterize(v, *levels)))),
Adjustment::Threshold { level } => {
// Split on L alone (on the 8-bit grid); the result is neutral black or white.
let t = (level * 255.0).round();
map_rgb(buf, |c| [if (lab_norm(c)[0].clamp(0.0, 1.0) * 255.0).round() >= t { 1.0 } else { 0.0 }; 3])
}
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) };
// The tone curve on L only. Added as the difference of two round trips (like
// `tone_in_space`) so a and b, and colours a round trip moves, stay put.
map_rgb(buf, |c| {
let n = lab_norm(c);
let l = curve(n[0]);
if l == n[0] {
return c;
}
let (before, after) = (lab_denorm(n), lab_denorm([l, n[1], n[2]]));
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
})
}
Adjustment::GradientMap { stops, reverse, dither } => {
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
for (i, p) in buf.px.iter_mut().enumerate() {
if p[3] <= 0.0 {
continue;
}
// Driven by L: equal lightness maps to the same stop colour whatever the chroma.
let l = lab_norm([p[0], p[1], p[2]])[0].clamp(0.0, 1.0);
let mut o = gradient(stops, if *reverse { 1.0 - l } else { l });
if *dither {
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
o = o.map(|v| (v + d).clamp(0.0, 1.0));
}
p[..3].copy_from_slice(&o);
}
}
_ => {}
}
}
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
@@ -293,7 +388,7 @@ pub fn tone_luts_depth(adj: &Adjustment, depth: Option<SampleType>) -> [Vec<f32>
/// through the conversions the document's surfaces use. The change is added as a difference of
/// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact.
fn tone_in_space(space: ToneSpace, luts: &[Vec<f32>; 4], c: [f32; 3]) -> [f32; 3] {
use photocraft_color::convert::{cmyk_to_rgb, lab_to_srgb, rgb_to_cmyk, srgb_to_lab};
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
let (before, after) = match space {
ToneSpace::Cmyk => {
let ink = rgb_to_cmyk(c);
@@ -336,21 +431,32 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
/// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the
/// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%.
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
let (mut hh, mut ss, ll) = rgb_to_hsl(c);
let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) };
if colorize {
hh = hue.rem_euclid(360.0) / 360.0;
ss = s.abs().min(1.0);
} else {
hh = (hh + hue / 360.0).rem_euclid(1.0);
ss = (ss * (1.0 + s)).clamp(0.0, 1.0);
// Colorize builds the colour at the lightened (or darkened) lightness, so black with
// Lightness +30 becomes a 30 % lightness colour, not grey (measured in Photoshop 27.11).
let (_, _, ll) = rgb_to_hsl(c);
return hsl_to_rgb(hue.rem_euclid(360.0) / 360.0, s.abs().min(1.0), lighten(ll));
}
let mut rgb = hsl_to_rgb(hh, ss, ll);
if l > 0.0 {
rgb = rgb.map(|v| v + (1.0 - v) * l);
} else if l < 0.0 {
rgb = rgb.map(|v| v * (1.0 + l));
}
rgb
// Photoshop applies Lightness first, then rotates the hue and scales the saturation of the
// result: Hue +40, Saturation +25, Lightness -10 turns (240, 160, 30) into (173, 243, 0), not
// (168, 230, 14) (Photoshop 27.11 corpus file adjustments/rgb8/hue-saturation).
let (hh, ss, ll) = rgb_to_hsl(c.map(lighten));
let hh = (hh + hue / 360.0).rem_euclid(1.0);
// Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it
// (measured in Photoshop 27.11); +100 % saturates any colour that has some.
let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0);
hsl_to_rgb(hh, ss, ll)
}
/// A colour range's Lightness, applied after the master edits. It moves each channel towards the
/// pixel's brightest channel when raised and towards its darkest when lowered, not towards white
/// or black: Blues +50 turns (0, 29, 115) into (57, 71, 115), Blues -50 turns (102, 128, 204) into
/// (102, 115, 153) (measured in Photoshop 27.11).
pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] {
let l = l.clamp(-1.0, 1.0);
let target = if l > 0.0 { c[0].max(c[1]).max(c[2]) } else { c[0].min(c[1]).min(c[2]) };
c.map(|v| v + (target - v) * l.abs())
}
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
@@ -502,10 +608,12 @@ pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] {
]
}
/// Photoshop Selective Color (approximation): each range shifts the cyan, magenta and yellow ink
/// (1 − R, G, B) by its percentage, plus its black percentage on every ink, weighted by how much
/// the colour belongs to the range. Relative mode scales the shift by the ink already present
/// (so it can't tint pure white); absolute adds it outright.
/// Photoshop Selective Color: each range shifts the cyan, magenta and yellow ink (1 − R, G, B) by
/// its percentage, plus its black percentage on every ink, weighted by how much the colour belongs
/// to the range. Relative mode scales the shift by the ink already present (so it can't tint pure
/// white). Absolute mode scales it by the room the ink has: adding ink by min(1, 2 × (1 − ink)),
/// removing it by min(1, 2 × ink), so Neutrals Cyan +50 darkens red 64 by 32, not 64 (all 72
/// absolute range × ink cases within 1 level of Photoshop 27.11).
pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32; 3] {
let w = selective_color_weights(c);
let mut delta = [0.0f32; 3];
@@ -516,7 +624,14 @@ pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32
let a = adj[r].map(|v| v / 100.0);
for i in 0..3 {
let ink = 1.0 - c[i];
let d = if relative { (a[i] + a[3]) * ink } else { a[i] + a[3] };
let d = a[i] + a[3];
let d = if relative {
d * ink
} else if d > 0.0 {
d * (2.0 * (1.0 - ink)).min(1.0)
} else {
d * (2.0 * ink).min(1.0)
};
delta[i] += d * wr;
}
}
@@ -938,6 +1053,30 @@ mod lookup_tests {
}
}
/// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's
/// cyan change).
#[test]
fn absolute_selective_color_matches_photoshop() {
// (range index, cyan %, grey, Photoshop's red)
let measured = [
(7, 50.0, 64u8, 32u8),
(7, 50.0, 192, 129),
(7, -50.0, 192, 223),
(7, -50.0, 64, 128),
(6, -50.0, 192, 224),
(6, -50.0, 240, 253),
(8, 50.0, 64, 32),
(8, 50.0, 16, 2),
];
for (range, cyan, grey, want) in measured {
let mut adj = [[0.0f32; 4]; 9];
adj[range][0] = cyan;
let g = grey as f32 / 255.0;
let got = selective_color([g; 3], false, &adj)[0] * 255.0;
assert!((got - want as f32).abs() <= 1.5, "range {range} cyan {cyan} grey {grey}: {got} vs {want}");
}
}
#[test]
fn selective_color_zero_is_identity_and_ranges_are_local() {
let zero = [[0.0f32; 4]; 9];
@@ -1193,6 +1332,51 @@ mod tone_tests {
}
}
/// Photoshop 27.11, measured: Hue/Saturation raising saturation, and Colorize with Lightness.
#[test]
fn hue_saturation_matches_photoshop() {
// (input, hue, saturation, lightness, colorize, Photoshop's output)
type Case = ([u8; 3], f32, f32, f32, bool, [u8; 3]);
let measured: [Case; 8] = [
([204, 102, 102], 0.0, 50.0, 0.0, false, [254, 51, 51]),
([204, 159, 102], 0.0, 50.0, 0.0, false, [254, 164, 51]),
([204, 102, 102], 0.0, -50.0, 0.0, false, [178, 127, 127]),
([0, 0, 0], 120.0, 100.0, 30.0, true, [0, 152, 0]),
([28, 28, 28], 120.0, 100.0, 30.0, true, [0, 192, 0]),
([160, 160, 160], 30.0, 25.0, -40.0, true, [120, 96, 73]),
([252, 252, 252], 30.0, 25.0, -40.0, true, [177, 151, 126]),
([255, 143, 0], 30.0, 25.0, -40.0, true, [95, 76, 58]),
];
for (input, hue, saturation, lightness, colorize, want) in measured {
let a = Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges: HueRange::defaults() };
let c = input.map(|v| v as f32 / 255.0);
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] };
apply(&a, &mut b);
for (i, w) in want.iter().enumerate() {
let got = b.px[0][i] * 255.0;
assert!((got - *w as f32).abs() <= 2.0, "{input:?} h{hue} s{saturation} l{lightness} colorize {colorize}, channel {i}: {got} vs {w}");
}
}
}
/// Photoshop 27.11, measured: a range's Lightness (Blues ±50) moves towards the brightest or darkest channel.
#[test]
fn range_lightness_matches_photoshop() {
let measured = [
([0u8, 29, 115], 0.5, [57u8, 71, 115]),
([0, 64, 255], 0.5, [125, 157, 255]),
([102, 128, 204], 0.5, [152, 165, 204]),
([102, 128, 204], -0.5, [102, 115, 154]),
([0, 18, 115], -0.5, [0, 9, 58]),
];
for (input, l, want) in measured {
let got = range_lightness(input.map(|v| v as f32 / 255.0), l);
for i in 0..3 {
assert!((got[i] * 255.0 - want[i] as f32).abs() <= 2.5, "{input:?} channel {i}: {} vs {}", got[i] * 255.0, want[i]);
}
}
}
#[test]
fn black_white_weights() {
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
@@ -1204,6 +1388,31 @@ mod tone_tests {
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
}
/// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint
/// is blended like the Color blend mode: its hue and saturation at the grey's luminosity.
#[test]
fn black_white_tint_matches_photoshop() {
let weights = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
let measured: [([u8; 3], u8, [u8; 3]); 6] = [
([255, 0, 0], 24, [81, 1, 1]),
([255, 0, 0], 120, [255, 64, 64]),
([255, 0, 0], 240, [255, 235, 235]),
([0xe1, 0xd3, 0xb3], 120, [133, 119, 87]),
([0xe1, 0xd3, 0xb3], 24, [33, 24, 1]),
([0x40, 0x20, 0x10], 120, [144, 112, 96]),
];
for (tint, grey, want) in measured {
let tint = Some(tint.map(|v| v as f32 / 255.0));
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[grey as f32 / 255.0; 4]] };
b.px[0][3] = 1.0;
apply(&Adjustment::BlackWhite { weights, tint }, &mut b);
for (i, w) in want.iter().enumerate() {
let got = b.px[0][i] * 255.0;
assert!((got - *w as f32).abs() <= 2.5, "tint {tint:?} grey {grey} channel {i}: {got} vs {w}");
}
}
}
#[test]
fn gradient_map_dither_stays_close() {
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
@@ -1214,6 +1423,106 @@ mod tone_tests {
assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6));
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
}
/// `adj` applied in a Lab document to one encoded sRGB colour, returned as (L, a, b).
fn in_lab(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Lab);
srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]])
}
/// A muted, in-gamut colour from (L, a, b).
fn lab(l: f32, a: f32, b: f32) -> [f32; 3] {
lab_to_srgb([l, a, b])
}
fn near(got: [f32; 3], want: [f32; 3], tol: f32) -> bool {
got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol)
}
#[test]
fn lab_invert_flips_l_a_and_b() {
let got = in_lab(&Adjustment::Invert, lab(30.0, 12.0, -18.0));
// n -> 1 - n: L -> 100 - L, a -> -a - 1, b -> -b - 1 (mirrored on the 0..255 grid).
assert!(near(got, [70.0, -13.0, 17.0], 0.6), "{got:?}");
// On the screen colour it would not be a mirror in Lab.
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]);
let c = lab(30.0, 12.0, -18.0);
rgb.px[0] = [c[0], c[1], c[2], 1.0];
apply(&Adjustment::Invert, &mut rgb);
assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0));
}
#[test]
fn lab_posterize_steps_l_a_and_b() {
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
// Two levels: each normalised channel snaps to 0 or 1 (L 100, a and b at +127 / -128).
assert!(near(got, srgb_to_lab(lab_denorm([1.0, 1.0, 0.0])), 1e-3), "{got:?}");
let grey = in_lab(&Adjustment::Posterize { levels: 255 }, lab(50.0, 0.0, 0.0));
assert!(near(grey, [50.0, 0.0, 0.0], 1.1), "255 levels is within a step of the identity: {grey:?}");
}
#[test]
fn lab_threshold_splits_on_l_with_a_neutral_result() {
let t = Adjustment::Threshold { level: 0.5 };
// A saturated blue (L ≈ 32) and yellow (L ≈ 97): black and white, nothing in between.
for (c, white) in [([0.0, 0.0, 1.0], false), ([1.0, 1.0, 0.0], true), (lab(52.0, 40.0, 30.0), true), (lab(48.0, -40.0, 30.0), false)] {
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
apply_in(&t, &mut b, Transfer::Srgb, None, ColorMode::Lab);
let v = if white { 1.0 } else { 0.0 };
assert_eq!(b.px[0], [v, v, v, 1.0], "{c:?}");
}
}
#[test]
fn lab_brightness_contrast_moves_l_only() {
for legacy in [false, true] {
let adj = Adjustment::BrightnessContrast { brightness: 40.0, contrast: 30.0, legacy };
let before = [45.0, 15.0, -20.0];
let got = in_lab(&adj, lab(before[0], before[1], before[2]));
assert!(got[0] > before[0] + 5.0, "{legacy}: L rises: {got:?}");
assert!((got[1] - before[1]).abs() < 0.5 && (got[2] - before[2]).abs() < 0.5, "{legacy}: a, b kept: {got:?}");
}
}
#[test]
fn lab_gradient_map_follows_l() {
let stops = vec![(0.0, [1.0, 0.0, 0.0]), (1.0, [0.0, 0.0, 1.0])];
for reverse in [false, true] {
let map = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
// A grey and a saturated colour with the same L map to the same colour.
let grey = in_lab(&map, lab(60.0, 0.0, 0.0));
let green = in_lab(&map, lab(60.0, -50.0, 40.0));
assert!(near(grey, green, 1e-3), "{reverse}: {grey:?} vs {green:?}");
let t = if reverse { 0.4 } else { 0.6 };
let want = srgb_to_lab([1.0 - t, 0.0, t]);
assert!(near(grey, want, 0.05), "{reverse}: {grey:?} vs {want:?}");
}
}
#[test]
fn other_modes_and_adjustments_keep_the_screen_colour_path() {
let c = [0.2, 0.5, 0.7, 1.0];
let lab_only = [Adjustment::Invert, Adjustment::Threshold { level: 0.47 }, Adjustment::Posterize { levels: 4 }];
for adj in
lab_only.iter().chain([&Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }])
{
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c));
apply_in(adj, &mut a, Transfer::Srgb, None, mode);
apply_depth(adj, &mut b, Transfer::Srgb, None);
assert_eq!(a.px == b.px, mode != ColorMode::Lab || !lab_channel_adjustment(adj), "{adj:?} in {mode:?}");
}
}
// Transparent pixels are left alone and odd values never panic.
let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]);
for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) {
apply_in(adj, &mut t, Transfer::Srgb, None, ColorMode::Lab);
let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]);
apply_in(adj, &mut odd, Transfer::Srgb, None, ColorMode::Lab);
}
assert!(t.px[0][1] == 2.0);
}
}
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
+3 -3
View File
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
}
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
/// named "Background" (how PSD import and File › New create it).
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
pub fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
l.has_background_traits()
}
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
+23
View File
@@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
assert!(got[3] <= 1.0);
}
/// The Background is found by its structure, not its English name: a file from a German or
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
/// bottom layer merely called "Background", without the Background's locks, is not one.
#[test]
fn deep_knockout_reveals_a_localized_background() {
for depth in DEPTHS {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let mut d = doc(depth, true);
d.layers[0].name = name.into();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, WHITE, name);
}
let mut d = doc(depth, true);
d.layers[0].locks = Default::default();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
}
}
+182 -35
View File
@@ -43,7 +43,7 @@ pub fn margin(layer: &Layer) -> i32 {
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
Effect::Stroke(s) => s.size,
Effect::Satin(s) => s.distance + s.size,
Effect::Satin(s) => satin_distance(s) + s.size,
Effect::BevelEmboss(b) => b.size + b.soften,
_ => 0.0,
};
@@ -882,6 +882,16 @@ fn contour_lut(c: &Contour) -> Option<Vec<f32>> {
}
}
/// Satin's coverage transfer: the contour read from its far end, `|c(1 − v) − c(1)|`. Measured in
/// Photoshop 27.11: Cone and Cone − Inverted Satins are identical, and a Gaussian Satin runs under
/// the diagonal (0.36 at 0.5) while the same Gaussian on a glow or gloss runs over it (0.65).
/// `None` = identity.
pub fn satin_lut(c: &Contour) -> Option<Vec<f32>> {
let lut = contour_lut(c)?;
let end = lut.last().copied().unwrap_or(1.0);
Some(lut.iter().rev().map(|v| (v - end).abs()).collect())
}
/// Interpolated lookup in a 0..=1 table (as the GPU's `lut()`).
fn lut_at(l: &[f32], v: f32) -> f32 {
let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32;
@@ -1114,27 +1124,24 @@ fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool, origin:
}
fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
let src = if inner {
match g.source {
GlowSource::Edge => shape.clone().map(|a| 1.0 - a),
GlowSource::Center => shape.clone(),
}
} else {
shape.clone()
};
// An inner glow is built from the edge inwards; a centre-source glow is its inverse, taken
// after the contour and Range (Photoshop 27.11, measured: the centre glow is exactly
// 1 − the edge glow, for both techniques).
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
let mut m = match g.technique {
GlowTechnique::Precise => {
// An inner glow, from either source, measures from the edge inwards; a centre glow is
// the inverse below (the distance outside is ≤ 0 everywhere inside, #966).
// Coverage falls linearly from 0.5 at the edge to 0 at `size + 1` px (centre to
// centre), solid within the spread / choke: after the default 50 % Range that is
// Photoshop's 1 − D / (size + 1) beyond the solid part (measured: outer and inner,
// sizes 10 and 20, spread 0 and 40 %, choke 0 and 30 %: within 1 level; other Range
// values are unmeasured). The distances here are 0.5 px short of centre to centre.
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
let solid = g.size * g.spread;
let soft = (g.size - solid).max(1e-3);
let soft = (g.size + 1.0 - solid).max(1e-3);
let mut m = Map::new(shape.w, shape.h, 0.0);
for (o, d) in m.v.iter_mut().zip(d) {
*o = if d <= solid { 1.0 } else { (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
}
if inner && g.source == GlowSource::Center {
m = m.map(|v| 1.0 - v);
let d = d + 0.5;
*o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
}
m
}
@@ -1142,16 +1149,13 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
if inner && g.source == GlowSource::Center {
// Brightest in the middle: coverage from the distance to the edge.
let mut e = dilate(&shape.clone().map(|a| 1.0 - a), r);
blur(&mut e, bw);
m = e.map(|v| 1.0 - v);
}
m
}
};
m = apply_lut(m, glow_lut(g));
if inner && g.source == GlowSource::Center {
m = m.map(|v| 1.0 - v);
}
noise(&mut m, g.noise, origin.0, origin.1);
if inner {
for (v, a) in m.v.iter_mut().zip(&shape.v) {
@@ -1461,7 +1465,9 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
BevelGeom {
paint,
width,
depth: b.depth * if smooth { width } else { size } * sign,
// A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the
// edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`).
depth: b.depth * if smooth { width } else { size / 2.0 } * sign,
pillow: b.style == BevelStyle::PillowEmboss,
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
}
@@ -1482,10 +1488,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
let sharp = sharp_edges(shape);
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
let mut h = Map::new(shape.w, shape.h, 0.0);
let soft = g.chisel_soft > 0.0;
for i in 0..h.v.len() {
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]);
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
}
if g.chisel_soft > 0.0 {
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
// Hard's across a straight edge); emboss styles keep the blur until measured.
if soft && g.paint == BevelPaint::Both {
tent(&mut h, g.chisel_soft);
}
h
@@ -1527,17 +1536,46 @@ pub struct TextureCtx<'a> {
}
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
///
/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel
/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of
/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp
/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge
/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a
/// pixel further out. All within 1 level of Photoshop on those profiles.
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 {
let ramp = size + 1.0;
match paint {
BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0),
BevelPaint::Both => {
if din > 0.0 {
0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0)
BevelPaint::Outer if !soft => {
let top = (size + 2.0) / ramp;
if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top }
}
// Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel.
BevelPaint::Outer => {
if dout > 0.0 {
1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0)
(size + 2.0) / ramp
}
}
BevelPaint::Both => {
let half = size / 2.0 + 0.5;
if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) }
}
BevelPaint::Inner if soft => {
if din > 0.0 {
((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp)
} else {
0.0
}
}
BevelPaint::Inner => {
if din > 0.0 {
((din + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.0
}
}
BevelPaint::Inner => (din / size).clamp(0.0, 1.0),
}
}
@@ -1555,7 +1593,9 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
// bevel-outer-smooth).
let reach_past = g.paint == BevelPaint::Both;
// Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop
// 27.11 shades it, half strength, at `size + 1½` px out).
let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth);
let on_bevel = |x: i64, y: i64| {
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
};
@@ -1659,8 +1699,14 @@ impl FxMaps {
}
}
/// The distance the two satin copies are offset by. Photoshop draws Distance 0 as 1 px (an
/// edge band of 2/size, measured in Photoshop 27.11), not as two copies cancelling out.
pub fn satin_distance(s: &photocraft_doc::effects::Satin) -> f32 {
s.distance.max(1.0)
}
fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
let (dx, dy) = offset(s.angle, s.distance);
let (dx, dy) = offset(s.angle, satin_distance(s));
let mut a = shape.shifted(dx, dy, 0.0);
let mut b = shape.shifted(-dx, -dy, 0.0);
// Two box passes of the satin size (fitted: 0.2 % mean error on psd-tools layer_effects).
@@ -1670,7 +1716,7 @@ fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
if s.invert {
m = m.map(|v| 1.0 - v);
}
let mut m = apply_contour(m, &s.contour);
let mut m = apply_lut(m, satin_lut(&s.contour));
for (v, a) in m.v.iter_mut().zip(&shape.v) {
*v *= a;
}
@@ -2340,6 +2386,40 @@ mod tests {
assert!(at(&maps[0], 20, 15) < 0.05);
}
/// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top
/// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the
/// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard
/// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out.
#[test]
fn chisel_facets_match_photoshop() {
let amount = |g: f32| {
let (se, ce) = 30f32.to_radians().sin_cos();
((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se)
};
let n = 80;
let shape = square(n, 20, 60);
let maps = |style, technique| {
let mut b = bevel_of(style, technique);
b.size = 10.0;
bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0
};
let at = |m: &Map, y: usize| m.v[y * n + 40];
let facet = amount(10.0 / 22.0);
let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
for y in 20..30 {
assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y));
}
assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end");
let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft);
assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20));
assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet");
let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard);
// Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½.
assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4);
assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70));
assert_eq!(at(&outer[1], 72), 0.0);
}
#[test]
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
let shape = square(40, 10, 30);
@@ -2883,6 +2963,51 @@ mod tests {
assert!(glow_lut(&g).is_none());
}
/// Photoshop 27.11, measured on a 128 px square (Normal, black, 100 %, Range 50 %): a precise
/// glow's coverage across the edge, and a centre-source inner glow as 1 − the edge glow.
#[test]
fn glows_match_photoshop() {
let glow = |technique, size: f32, spread: f32, source| Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
paint: FxPaint::Color(photocraft_color::Color::BLACK),
technique,
spread,
size,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source,
};
let n = 60;
let mut shape = Map::new(n, n, 0.0);
for y in 20..40 {
for x in 20..40 {
shape.v[y * n + x] = 1.0;
}
}
let at = |m: &Map, x: usize| m.v[30 * n + x];
// Outer, precise, size 10: Photoshop's black glow over white reads 47, 93, 140, 186, 232
// at 2, 4, 6, 8 and 10 px out from the edge pixel (alpha 1 − D / 11).
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.0, GlowSource::Edge), false, (0, 0));
for (d, ps) in [(2, 47.0f32), (4, 93.0), (6, 140.0), (8, 186.0), (10, 232.0)] {
let got = 255.0 * (1.0 - at(&m, 20 - d));
assert!((got - ps).abs() <= 1.5, "outer precise at {d} px: {got} vs Photoshop {ps}");
}
// Spread 40 %: solid for the first 4 px.
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.4, GlowSource::Edge), false, (0, 0));
assert!((at(&m, 16) - 1.0).abs() < 1e-6 && at(&m, 14) < 1.0);
// Centre source = 1 − edge, for both techniques.
for t in [GlowTechnique::Precise, GlowTechnique::Softer] {
let e = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Edge), true, (0, 0));
let c = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Center), true, (0, 0));
for x in 20..40 {
assert!((at(&c, x) - (1.0 - at(&e, x))).abs() < 1e-5, "{t:?} at {x}");
}
}
}
#[test]
fn offsets_follow_light() {
let (dx, dy) = offset(120.0, 10.0);
@@ -2899,6 +3024,28 @@ mod tests {
m
}
#[test]
fn satin_at_distance_zero_matches_photoshop() {
// Photoshop 27.11, Satin black 100 %, angle 90°, Linear contour, distance 0, size 5 over a
// #808080 square: the rows from the top edge read 82, 102, 123, 128.
let s = photocraft_doc::effects::Satin {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
color: photocraft_color::Color::BLACK,
angle: 90.0,
distance: 0.0,
size: 5.0,
contour: Contour::Linear,
anti_alias: false,
invert: false,
};
let m = satin_map(&square_shape(60), &s);
for (row, gray) in [(5, 82.0), (7, 102.0), (9, 123.0), (11, 128.0), (30, 128.0)] {
let want = 1.0 - gray / 128.0;
let got = m.v[row * 60 + 30];
assert!((got - want).abs() < 0.02, "row {row}: {got} vs {want}");
}
}
fn shadow(noise: f32, origin: (i32, i32)) -> Map {
let s = Shadow {
common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0),
+56 -9
View File
@@ -24,6 +24,7 @@ pub mod fill_layout;
pub mod gradient_fill;
pub mod masks;
pub mod multichannel;
pub mod native;
pub mod pattern;
pub mod proxy;
pub mod psblend;
@@ -232,20 +233,48 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
/// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
/// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
///
/// Fully transparent pixels are written as the default pixel. Converting transparent black
/// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would
/// otherwise count as content: the result would span the whole canvas.
///
/// A CMYK document of plain Normal layers is composited in its ink values instead
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778).
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
composite_to_surface(doc, doc.bounds(), fmt, background)
}
/// [`flatten_to_surface`] over `rect` only (the rest of the surface stays empty).
pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
let paper = background.is_some_and(|bg| bg == [1.0; 3]);
if (background.is_none() || paper)
&& let Some(s) = native::flatten_cmyk(doc, rect, fmt, paper)
{
return s;
}
let mut s = Surface::new(fmt);
let n = fmt.channels();
let empty = s.default_pixel();
let mut data = Vec::new();
let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
// The composite separates back to CMYK through the document's own profile, the one its
// pixels were read through.
let cmyk = cmyk_space(doc);
let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> {
let band = match background {
Some(bg) => band.over_background(bg),
None => band,
};
data.clear();
data.resize(band.px.len() * n, 0.0);
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
photocraft_raster::from_rgba_into(&fmt, *p, out);
}
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
out.copy_from_slice(&empty);
} else {
photocraft_raster::from_rgba_into(&fmt, *p, out);
}
}
});
s.write_region(band.rect, &data);
Ok(())
});
@@ -294,13 +323,19 @@ pub fn thumbnail(doc: &Document, max_side: u32) -> Rgba8Image {
thumbnail_buffer(doc, max_side).to_rgba8()
}
/// Width and height of [`thumbnail`]`(doc, max_side)`.
pub fn thumbnail_size(doc: &Document, max_side: u32) -> (u32, u32) {
let b = doc.bounds();
let longest = b.width().max(b.height()).max(1);
let scale = (max_side as f32 / longest as f32).min(1.0);
(((b.width() as f32 * scale).round() as u32).max(1), ((b.height() as f32 * scale).round() as u32).max(1))
}
/// [`thumbnail`] as straight-alpha floats (for callers that colour-convert it first).
pub fn thumbnail_buffer(doc: &Document, max_side: u32) -> Buffer {
let b = doc.bounds();
let longest = b.width().max(b.height()).max(1);
let scale = (max_side as f32 / longest as f32).min(1.0);
let w = ((b.width() as f32 * scale).round() as u32).max(1);
let h = ((b.height() as f32 * scale).round() as u32).max(1);
let (w, h) = thumbnail_size(doc, max_side);
let k = longest / max_side.max(1).saturating_mul(2);
if k >= 2 && doc.size.area() > PROXY_THUMBNAIL_PIXELS && proxy::proxy_faithful(doc) {
render_reduced(&proxy::proxy_document(doc, k), w, h)
@@ -1248,7 +1283,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
// Adjustment layers transform the backdrop, then blend the result back in.
if let LayerContent::Adjustment(adj) = &layer.content {
let mut adjusted = backdrop.clone();
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
advanced::composite_clipped(clipped, &mut adjusted, cx);
let mv = mask_vals(layer, rect, cx);
@@ -1589,7 +1624,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
let rect = base.rect;
if let LayerContent::Adjustment(adj) = &layer.content {
let mut adjusted = base.clone();
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
let mv = mask_vals(layer, rect, cx);
for (i, p) in base.px.iter_mut().enumerate() {
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
@@ -1674,6 +1709,18 @@ fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity
/// [`blend_into`] mixing coverage in a `gamma` space (type layers).
fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
// The blend space is constant in this loop. Some TLS implementations synchronise even a
// boolean read, so select the common Normal kernel once instead of once per pixel.
if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) {
for (i, b) in backdrop.px.iter_mut().enumerate() {
let s = src.px[i];
if s[3] <= 0.0 {
continue;
}
*b = psblend::composite_normal_rgb(*b, s, opacity);
}
return;
}
let rect = backdrop.rect;
let w = rect.width() as i32;
for (i, b) in backdrop.px.iter_mut().enumerate() {
+12 -3
View File
@@ -3,7 +3,8 @@
//! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity
//! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks
//! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is
//! how Photoshop previews spot channels.
//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document
//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it.
use photocraft_doc::Document;
use photocraft_geom::Rect;
@@ -15,7 +16,7 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer {
let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]);
let mut vals = Vec::new();
for ch in &doc.channels {
let Some((ink, solidity)) = ch.spot else { continue };
let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0));
if ch.surface.content_bounds().intersect(&rect).is_empty() {
continue;
}
@@ -65,7 +66,15 @@ mod tests {
assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]);
assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]);
assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]);
// Alpha channels and other modes are not inks.
// An alpha-type channel prints as a black ink.
let mut k = Surface::new(fmt);
k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.0, 0.0]);
doc.channels.push(AlphaChannel::new("Alpha", k));
let r = crate::flatten(&doc);
// 0.5 is stored as 128/255 in an 8-bit channel: within one 8-bit step.
assert!((r.px[0][0] - 0.5).abs() <= 1.0 / 255.0 && (r.px[0][1] - 0.5).abs() <= 1.0 / 255.0, "{:?}", r.px[0]);
assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]);
// Other modes have no inks.
doc.mode = ColorMode::Rgb;
assert_eq!(crate::flatten(&doc).px[1][3], 0.0);
}
+101
View File
@@ -0,0 +1,101 @@
//! Mode-native flattening of plain CMYK layer stacks (#3778).
//!
//! The compositor works in display RGB, so flattening a CMYK document through it reads every ink
//! value through the profile and separates it again: even a lone Background comes back with
//! different ink. When every visible layer is a plain raster layer in Normal mode (no masks,
//! effects, clipping or advanced blending), the composite needs no colour conversion at all:
//! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored
//! values and an opaque layer keeps them exactly.
use photocraft_color::blend::BlendMode;
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Process channels of a CMYK pixel.
const INKS: usize = 4;
/// A layer the native path can composite: a visible raster layer the compositor would only blend
/// Normally at its opacity.
fn plain(layer: &Layer) -> Option<&Surface> {
let LayerContent::Raster(s) = &layer.content else { return None };
let simple = layer.blend == BlendMode::Normal
&& !layer.clipped
&& layer.mask.as_ref().is_none_or(|m| !m.enabled)
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
&& !crate::effects::has_effects(layer)
&& layer.excluded_channels == 0
&& layer.blend_if.is_default()
&& layer.advanced.is_default()
&& layer.video.is_none()
&& s.format().mode == ColorMode::Cmyk;
simple.then_some(s)
}
/// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the
/// document isn't CMYK or a visible layer needs the full compositor (see the module docs).
/// `paper` flattens onto white paper (no ink) and makes the result opaque.
pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option<Surface> {
if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) {
return None;
}
let mut stack = Vec::new();
for layer in doc.layers.iter().filter(|l| l.visible) {
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
stack.push((plain(layer)?, alpha));
}
let mut out = Surface::new(fmt);
if rect.is_empty() {
return Some(out);
}
let n = fmt.channels();
let w = rect.width() as usize;
let rows = crate::band_rows_for(rect.width(), 0);
let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new());
let mut y = rect.y0;
while y < rect.y1 {
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
// Straight C, M, Y, K, alpha per pixel, starting transparent.
acc.clear();
acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]);
for &(surf, opacity) in &stack {
if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
continue;
}
let sf = surf.format();
let sn = sf.channels();
surf.read_region_into(band, &mut src);
for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) {
let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity;
if sa <= 0.0 {
continue;
}
let oa = sa + d[INKS] * (1.0 - sa);
let keep = d[INKS] * (1.0 - sa);
for c in 0..INKS {
// An opaque pixel replaces what is below exactly (no rounding through the mix).
d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa };
}
d[INKS] = oa;
}
}
data.clear();
data.resize(acc.len() * n, 0.0);
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
// White paper carries no ink, so flattening onto it scales the ink by coverage.
let a = p[INKS];
let k = if paper && a < 1.0 { a } else { 1.0 };
for c in 0..INKS {
o[c] = p[c] * k;
}
if fmt.alpha {
o[INKS] = if paper { 1.0 } else { a };
}
}
out.write_region(band, &data);
y = band.y1;
}
out.prune();
Some(out)
}
+20 -4
View File
@@ -45,7 +45,7 @@ impl<'a> PreparedPatterns<'a> {
pub(crate) fn get(&self, id: &str, name: &str) -> Option<Arc<Tile>> {
let p = photocraft_doc::pattern::find(self.patterns, id, name)?;
if p.is_empty() {
if !p.is_paintable() {
return None;
}
let bytes = usize::try_from(p.width)
@@ -87,9 +87,13 @@ impl<'a> PreparedPatterns<'a> {
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
/// pattern in colour in its picker, filled in grey).
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
/// [paintable](Pattern::is_paintable).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let mut px = vec![[0.0f32; 4]; (p.width as usize).saturating_mul(p.height as usize)];
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
return Vec::new();
};
let mut px = vec![[0.0f32; 4]; n];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
@@ -117,8 +121,9 @@ impl Tile {
}
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
/// `None` when the pattern isn't [paintable](Pattern::is_paintable).
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
if p.is_empty() {
if !p.is_paintable() {
return None;
}
let (w, h) = (p.width as usize, p.height as usize);
@@ -255,6 +260,17 @@ mod tests {
assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4
}
#[test]
fn patterns_too_large_to_paint_are_skipped_without_allocating() {
// Only the size is declared (no tiles): a hostile file can claim any size.
let p = Pattern { id: "big".into(), name: "Big".into(), width: 9000, height: 8000, surface: Surface::new(PixelFormat::RGBA8) };
assert!(!p.is_paintable());
assert!(Tile::new(&p).is_none());
assert!(pattern_rgba(&p, ColorMode::Rgb).is_empty());
let list = [p];
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
}
#[test]
fn link_phase_scale_and_angle_move_the_origin() {
let t = Tile::new(&checker()).unwrap();
+165 -2
View File
@@ -83,15 +83,52 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document {
}
/// Whether a proxy composite is a faithful reduction of `doc`: layer effects (whose sizes are in
/// document pixels) and vector masks (paths in document coordinates) would not scale with it.
/// document pixels) and nonempty vector masks (paths in document coordinates) would not scale
/// with it. Empty vector masks have constant coverage, including inversion and density.
/// Nonempty paths can still anchor fill layouts even when their mask density is zero.
pub fn proxy_faithful(doc: &Document) -> bool {
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled))
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::vector::{Knot, Path, Subpath, VectorMask};
use photocraft_doc::{Effect, Fill, GradientStyle, LayerMask};
fn two_colors(size: Size, depth: SampleType) -> Document {
let mut doc = Document::new("mask", size, ColorMode::Rgb, depth);
let format = doc.pixel_format();
for (name, color) in [("blue", [0.0, 0.0, 1.0, 1.0]), ("red", [1.0, 0.0, 0.0, 1.0])] {
doc.layers.push(Layer::new(name, LayerContent::Raster(Surface::with_default(format, &color))));
}
doc
}
fn assert_same_pixels(actual: &crate::Buffer, expected: &crate::Buffer) {
assert_same_pixels_within(actual, expected, 1e-6);
}
fn assert_same_pixels_within(actual: &crate::Buffer, expected: &crate::Buffer, tolerance: f32) {
assert_eq!(actual.rect, expected.rect);
assert_eq!(actual.px.len(), expected.px.len());
for (a, b) in actual.px.iter().zip(&expected.px) {
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() <= tolerance), "{a:?} != {b:?}");
}
}
fn u16_coverage(value: f32) -> f32 {
// Feathered masks use the existing U16 combined-mask surface, including its default.
(value.clamp(0.0, 1.0) * 65535.0 + 0.5).floor() / 65535.0
}
fn opacity_reference(doc: &Document, coverage: f32) -> Document {
let mut reference = doc.clone();
reference.layers[1].vector_mask = None;
reference.layers[1].opacity *= coverage;
reference
}
#[test]
fn downsample_picks_every_kth_pixel() {
@@ -134,4 +171,130 @@ mod tests {
assert_eq!((s.pixel(7, 15)[0], s.pixel(8, 0)[0]), (1.0, 0.0));
assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none());
}
#[test]
fn proxy_accepts_empty_paths_but_keeps_geometry_and_effect_guards() {
let mut doc = two_colors(Size::new(32, 16), SampleType::F32);
doc.layers[1].vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::default()])));
assert!(proxy_faithful(&doc));
// A lone knot has no fillable segments, but still carries document coordinates.
doc.layers[1].vector_mask.as_mut().unwrap().path.subpaths[0].knots.push(Knot::corner(12.0, 8.0));
assert!(!proxy_faithful(&doc));
doc.layers[1].vector_mask.as_mut().unwrap().enabled = false;
assert!(proxy_faithful(&doc));
doc.layers[1].vector_mask = Some(VectorMask::new(Path::default()));
doc.layers[1].effects.items.push(Effect::default_drop_shadow());
doc.layers[1].effects.enabled = true;
assert!(!proxy_faithful(&doc));
doc.layers[1].effects.items[0].set_enabled(false);
assert!(proxy_faithful(&doc));
}
#[test]
fn empty_vector_masks_keep_constant_coverage_at_every_depth() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut doc = two_colors(Size::new(32, 16), depth);
for path in [Path::default(), Path::new(vec![Subpath::default()])] {
for inverted in [false, true] {
for density in [0.0, 0.25, 1.0] {
for feather in [0.0, 4.0] {
let mut mask = VectorMask::new(path.clone());
mask.path.inverted = inverted;
mask.density = density;
mask.feather = feather;
doc.layers[1].vector_mask = Some(mask);
assert!(proxy_faithful(&doc));
let coverage = if inverted { 1.0 - density } else { 1.0 };
let coverage = if feather > 0.0 { u16_coverage(coverage) } else { coverage };
let expected = [coverage, 0.0, 1.0 - coverage, 1.0];
let full = crate::render(&doc, doc.bounds());
for pixel in &full.px {
assert!(pixel.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{depth:?} {inverted} {density} {feather}: {pixel:?}");
}
let proxy = proxy_document(&doc, 4);
assert_eq!(proxy.layers[1].vector_mask, doc.layers[1].vector_mask);
let reference = proxy_document(&opacity_reference(&doc, coverage), 4);
assert_same_pixels(&crate::render(&proxy, proxy.bounds()), &crate::render(&reference, reference.bounds()));
}
}
}
}
}
}
#[test]
fn empty_vector_mask_multiplies_a_feathered_pixel_mask_on_the_proxy() {
let mut doc = two_colors(Size::new(32, 16), SampleType::F32);
let mut surface = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.25]);
surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.75]);
doc.layers[1].mask = Some(LayerMask { surface, density: 0.5, feather: 2.0, ..LayerMask::reveal_all() });
let mut mask = VectorMask::new(Path { inverted: true, ..Path::default() });
mask.density = 0.75;
mask.feather = 8.0;
doc.layers[1].vector_mask = Some(mask);
assert!(proxy_faithful(&doc));
let reference = opacity_reference(&doc, 0.25);
for k in [1, 2, 4] {
let (actual, expected) = (proxy_document(&doc, k), proxy_document(&reference, k));
// Q(0.25 * pixel) versus 0.25 * Q(pixel): U16 rounding differs by at most
// 0.625 / 65535. Allow one U16 level plus a small float arithmetic allowance.
assert_same_pixels_within(&crate::render(&actual, actual.bounds()), &crate::render(&expected, expected.bounds()), 1.0 / 65535.0 + 1e-6);
}
}
#[test]
fn empty_vector_masks_keep_the_pixel_mask_gradient_frame() {
let mut doc = two_colors(Size::new(32, 16), SampleType::F32);
doc.layers[1].content = LayerContent::Fill(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false));
let mut pixel = LayerMask::hide_all();
pixel.surface.fill_rect(Rect::new(8, 4, 24, 12), &[1.0]);
doc.layers[1].mask = Some(pixel);
for inverted in [false, true] {
let mut mask = VectorMask::new(Path { inverted, ..Path::default() });
mask.density = 0.5;
mask.feather = 3.0;
doc.layers[1].vector_mask = Some(mask);
assert!(proxy_faithful(&doc));
assert_eq!(crate::fill_frame(&doc.layers[1], doc.bounds()), Rect::new(8, 4, 24, 12));
let actual = proxy_document(&doc, 4);
assert_eq!(crate::fill_frame(&actual.layers[1], actual.bounds()), Rect::new(2, 1, 6, 3));
let reference = proxy_document(&opacity_reference(&doc, u16_coverage(if inverted { 0.5 } else { 1.0 })), 4);
assert_same_pixels(&crate::render(&actual, actual.bounds()), &crate::render(&reference, reference.bounds()));
}
}
#[test]
fn zero_density_geometry_still_blocks_the_proxy_and_anchors_a_gradient() {
let mut doc = Document::new("gradient", Size::new(64, 32), ColorMode::Rgb, SampleType::F32);
let fill = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false);
let mut layer = Layer::new("gradient", LayerContent::Fill(fill));
let path = Path::new(vec![Subpath::polygon(&[(16.0, 8.0), (48.0, 8.0), (48.0, 24.0), (16.0, 24.0)])]);
let mut mask = VectorMask::new(path);
mask.density = 0.0;
layer.vector_mask = Some(mask);
doc.layers.push(layer);
assert!(!proxy_faithful(&doc));
assert_eq!(crate::fill_frame(&doc.layers[0], doc.bounds()), Rect::new(16, 8, 48, 24));
// Density zero reveals all pixels, but the unscaled path would still move the fill.
let forced_proxy = proxy_document(&doc, 4);
let exact = crate::render_reduced(&doc, 16, 8);
let wrong = crate::render(&forced_proxy, forced_proxy.bounds());
assert!(exact.px.iter().zip(&wrong.px).any(|(a, b)| (a[0] - b[0]).abs() > 0.1));
}
#[test]
fn large_thumbnail_keeps_inverted_empty_mask_coverage() {
// Default-only surfaces exercise the large-document branch without allocating 17 MP.
let mut doc = two_colors(Size::new(4200, 4200), SampleType::F32);
let mut mask = VectorMask::new(Path { inverted: true, ..Path::default() });
mask.density = 0.75;
mask.feather = 8.0;
doc.layers[1].vector_mask = Some(mask);
assert!(doc.size.area() > crate::PROXY_THUMBNAIL_PIXELS);
assert!(proxy_faithful(&doc));
let actual = crate::thumbnail_buffer(&doc, 4);
assert_eq!(actual.rect, Rect::new(0, 0, 4, 4));
let coverage = u16_coverage(0.25);
assert_eq!(actual.px, vec![[coverage, 0.0, 1.0 - coverage, 1.0]; 16]);
}
}
+24 -7
View File
@@ -210,13 +210,7 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
return [r[0], r[1], r[2], ao];
}
if mode == BlendMode::Normal {
// Fast path: B(Cb, Cs) = Cs.
if ao <= 0.0 {
return [0.0; 4];
}
let kb = ab * (1.0 - as_);
let inv = 1.0 / ao;
return [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao];
return normal_rgb(backdrop, source, as_, ao);
}
let cb = [backdrop[0], backdrop[1], backdrop[2]];
let cs = [source[0], source[1], source[2]];
@@ -232,6 +226,29 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
out
}
/// Normal mixing outside Lab, for callers that select the blend space once per buffer.
#[inline]
pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
let ab = backdrop[3];
let as_ = source[3] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let ao = as_ + ab * (1.0 - as_);
normal_rgb(backdrop, source, as_, ao)
}
#[inline]
fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] {
// B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha.
if ao <= 0.0 {
return [0.0; 4];
}
let kb = backdrop[3] * (1.0 - as_);
let inv = 1.0 / ao;
[(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]
}
/// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by
/// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45
/// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a
+284
View File
@@ -40,6 +40,147 @@ fn normal_layer_over_background() {
assert!(close4(px(&d, 5, 1), [1.0; 4]));
}
// The pre-optimisation Normal formula, independent of the production helper. Its operation
// order matters: an algebraically equivalent premultiplied mix can round differently.
fn original_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
let ab = backdrop[3];
let as_ = source[3] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 4];
}
let kb = ab * (1.0 - as_);
let inv = 1.0 / ao;
[(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]
}
#[test]
fn normal_rgb_kernel_preserves_scalar_bits() {
let subnormal = f32::from_bits(1);
let backdrops = [[0.0, -0.0, 2.0, 0.0], [0.2, 0.4, 0.6, 0.5], [2.0, -0.25, 4.0, 1.0], [-1.0, 0.01, 1.0, -0.5], [1.0, -0.0, f32::MIN_POSITIVE, subnormal]];
let sources = [
[0.9, 0.1, 0.3, 1.0],
[0.25, 2.0, -1.0, 0.5],
[subnormal, 1e-20, 1.0, subnormal],
[0.5, 0.2, 0.7, -0.0],
[0.5, 0.2, 0.7, -0.5],
[f32::NAN, 0.2, 0.7, 0.0],
];
for b in backdrops {
for s in sources {
for opacity in [-1.0, -0.0, 0.0, 0.5, 1.0, 2.0] {
let want = original_normal_rgb(b, s, opacity);
let got = psblend::composite_normal_rgb(b, s, opacity);
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{b:?} / {s:?} / {opacity}");
}
}
}
}
fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) {
let rect = Rect::new(-3, -2, 5, 0);
let mut bottom = Surface::new(fmt);
let mut top = Surface::new(fmt);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = ((y - rect.y0) * rect.width() as i32 + x - rect.x0) as usize;
let t = i as f32 / 16.0;
let b = [2.0 * t, 0.4, -0.25 + t, [0.0, 0.25, 0.75, 1.0][i % 4]];
let s = [0.9 - t, 0.1 + t, 0.3, [0.0, -0.0, 0.5, 1.0][i % 4]];
bottom.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, b));
top.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, s));
}
}
(surface_to_buffer(&bottom, rect), surface_to_buffer(&top, rect))
}
// Keep the old buffer dispatch as an oracle for the specialised loop and all of its fallbacks.
fn original_blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
let rect = backdrop.rect;
let w = rect.width() as i32;
for (i, b) in backdrop.px.iter_mut().enumerate() {
let mut s = src.px[i];
if s[3] <= 0.0 {
continue;
}
let mut mode = mode;
if mode == BlendMode::Dissolve {
let x = rect.x0 + (i as i32 % w);
let y = rect.y0 + (i as i32 / w);
s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
mode = BlendMode::Normal;
*b = blend::composite(mode, *b, s, 1.0);
continue;
}
*b = blend::composite_gamma(mode, *b, s, opacity, gamma);
}
}
fn assert_buffer_bits(got: &Buffer, want: &Buffer) {
assert_eq!(got.rect, want.rect);
assert_eq!(got.px.len(), want.px.len());
for (i, (got, want)) in got.px.iter().zip(&want.px).enumerate() {
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "pixel {i}: {got:?} vs {want:?}");
}
}
#[test]
fn normal_rgb_buffer_dispatch_and_fallbacks_preserve_bits() {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let (bottom, top) = normal_rgb_test_buffers(fmt);
for lab in [false, true] {
psblend::with_lab_mix(lab, || {
psblend::with_hdr(fmt.sample == SampleType::F32, || {
for mode in BlendMode::LAYER_MODES {
for gamma in [1.0, psblend::TEXT_GAMMA] {
for opacity in [0.0, 0.5, 1.0] {
let mut got = bottom.clone();
let mut want = bottom.clone();
original_blend_into_g(&mut want, &top, mode, opacity, gamma);
blend_into_g(&mut got, &top, mode, opacity, gamma);
assert_buffer_bits(&got, &want);
}
}
}
});
});
}
}
}
#[test]
fn normal_rgb_buffer_preserves_alpha_skip_and_nested_scopes() {
let rect = Rect::new(-1, -1, 2, 0);
let bottom = Buffer::filled(rect, [-0.0, 0.4, 2.0, 0.75]);
let top = Buffer { rect, px: vec![[f32::NAN, 0.3, 0.1, -0.5], [0.2, 0.3, 0.1, -0.0], [0.2, 0.3, 0.1, f32::NAN]] };
psblend::with_lab_mix(true, || {
psblend::with_hdr(true, || {
psblend::with_lab_mix(false, || {
psblend::with_hdr(false, || {
let mut got = bottom.clone();
let mut want = bottom.clone();
original_blend_into_g(&mut want, &top, BlendMode::Normal, -1.0, 1.0);
blend_into_g(&mut got, &top, BlendMode::Normal, -1.0, 1.0);
assert_buffer_bits(&got, &want);
assert_eq!(got.px[0].map(f32::to_bits), bottom.px[0].map(f32::to_bits));
assert_eq!(got.px[1].map(f32::to_bits), bottom.px[1].map(f32::to_bits));
});
assert!(psblend::HDR.with(|h| h.get()));
});
assert!(psblend::LAB_MIX.with(|l| l.get()));
assert!(psblend::HDR.with(|h| h.get()));
let mut got = bottom.clone();
let mut want = bottom.clone();
original_blend_into_g(&mut want, &top, BlendMode::Normal, 0.5, 1.0);
blend_into_g(&mut got, &top, BlendMode::Normal, 0.5, 1.0);
assert_buffer_bits(&got, &want);
});
});
}
#[test]
fn opacity_and_fill_multiply() {
let mut d = doc_white(4, 4);
@@ -463,6 +604,34 @@ fn cmyk_document_renders_via_rgb() {
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
}
/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened
/// surface must still treat them as empty, or its content spans the whole canvas.
#[test]
fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() {
let modes = [
(ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()),
(ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]),
(ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]),
(ColorMode::Lab, &[SampleType::U8, SampleType::U16]),
];
for (mode, samples) in modes {
for &sample in samples {
let mut d = Document::new("t", Size::new(300, 200), mode, sample);
let fmt = PixelFormat::new(mode, sample, true);
let mut l = Layer::raster("dot", fmt);
let opaque: Vec<f32> = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque);
d.layers.push(l);
let s = flatten_to_surface(&d, fmt, None);
assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}");
// Over a background everything is content, as before.
let fmt_opaque = PixelFormat::new(mode, sample, false);
let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0]));
assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque");
}
}
}
#[test]
fn sixteen_bit_and_float_layers_composite() {
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] {
@@ -2203,3 +2372,118 @@ fn outer_glow_does_not_paint_through_a_zero_fill_layer() {
assert!(opaque[0] > 0.9 && opaque[1] > 0.8, "{technique:?}: opaque fill disappeared: {opaque:?}");
}
}
// Lab documents: Invert mirrors L, a and b; Threshold splits on L with a neutral result; Gradient
// Map follows L. Rendered through the document compositor at every depth.
#[test]
fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() {
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
let c = lab_to_srgb([30.0, 12.0, -18.0]);
for depth in SampleType::ALL {
let render = |adj: Adjustment| {
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(c[0], c[1], c[2]));
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
let p = flatten(&d).px[5];
srgb_to_lab([p[0], p[1], p[2]])
};
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
let inv = render(Adjustment::Invert);
assert!(near(inv, [70.0, -13.0, 17.0], 1.0), "{depth:?}: {inv:?}");
let th = render(Adjustment::Threshold { level: 0.25 });
assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}");
let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false });
let want = srgb_to_lab([0.3; 3]);
assert!(near(gm, want, 1.0), "{depth:?}: L 30 → 30 % of the way: {gm:?} vs {want:?}");
}
}
/// `adj` over a one-colour Lab document (background `rgb`) at `depth`, read back as Lab.
fn lab_doc_adjusted(rgb: [f32; 3], adj: Adjustment, depth: SampleType) -> [f32; 3] {
let mut doc = Document::with_background("lab", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(rgb[0], rgb[1], rgb[2]));
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
let p = px(&doc, 1, 1);
photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])
}
fn near3(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, want {want:?}");
}
/// A muted, in-gamut colour (L ≈ 46, a ≈ 10, b ≈ -20) and its Lab coordinates.
const MUTED: [f32; 3] = [0.40, 0.42, 0.60];
#[test]
fn lab_documents_invert_and_posterize_l_a_and_b() {
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
for depth in SampleType::ALL {
// Invert: every normalised channel n -> 1 - n, so L -> 100 - L and a, b -> their opposites
// (-a - 1 on the 0..255 grid).
let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth);
near3(inv, [100.0 - lab[0], -lab[1] - 1.0, -lab[2] - 1.0], 1.0, &format!("{depth:?} invert"));
// An RGB invert would land elsewhere: lightness 100 - L is the Lab rule.
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
// step: L ≈ 50 and a, b ≈ -1, a neutral grey).
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3));
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
near3(post, want, 1.0, &format!("{depth:?} posterize"));
}
}
#[test]
fn lab_documents_brightness_contrast_moves_lightness_only() {
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
for depth in SampleType::ALL {
for legacy in [true, false] {
let got = lab_doc_adjusted(MUTED, Adjustment::BrightnessContrast { brightness: 40.0, contrast: 20.0, legacy }, depth);
assert!(got[0] > lab[0] + 5.0, "{depth:?} legacy {legacy}: {got:?}");
near3([lab[0], got[1], got[2]], lab, 0.75, &format!("{depth:?} legacy {legacy}: a, b kept"));
}
}
}
#[test]
fn lab_documents_threshold_on_lightness_with_a_neutral_result() {
for depth in SampleType::ALL {
// A saturated yellow is light (L ≈ 97): white, not a coloured result.
let yellow = lab_doc_adjusted([1.0, 1.0, 0.0], Adjustment::Threshold { level: 0.5 }, depth);
near3(yellow, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} yellow"));
// Pure blue has L ≈ 30: black.
let blue = lab_doc_adjusted([0.0, 0.0, 1.0], Adjustment::Threshold { level: 0.5 }, depth);
near3(blue, [0.0, 0.0, 0.0], 0.5, &format!("{depth:?} blue"));
// Mid grey L 53.4 against level 128 (n 0.502): Lab L decides, not RGB luminance.
let level = 0.52;
let grey = lab_doc_adjusted([0.5; 3], Adjustment::Threshold { level }, depth);
near3(grey, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} grey"));
}
}
#[test]
fn lab_documents_gradient_map_follows_lightness() {
let stops = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])];
// A grey and a saturated colour of the same L map to the same colour.
let red = [0.9, 0.1, 0.1];
let l = photocraft_color::convert::srgb_to_lab(red)[0];
let grey = photocraft_color::convert::lab_to_srgb([l, 0.0, 0.0]);
for depth in SampleType::ALL {
for reverse in [false, true] {
let adj = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
let a = lab_doc_adjusted(red, adj.clone(), depth);
let b = lab_doc_adjusted(grey, adj.clone(), depth);
near3(a, b, 1.0, &format!("{depth:?} reverse {reverse}"));
let t = if reverse { 1.0 - l / 100.0 } else { l / 100.0 };
let want = photocraft_color::convert::srgb_to_lab([t, t, 1.0 - t]);
near3(a, want, 1.5, &format!("{depth:?} reverse {reverse}"));
}
}
// In RGB documents the map still follows RGB luminance: red and grey differ there.
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
let adj = Adjustment::GradientMap { stops, reverse: false, dither: false };
adjust::apply_in(&adj, &mut buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
let mut grey_buf = Buffer::filled(Rect::new(0, 0, 1, 1), [grey[0], grey[1], grey[2], 1.0]);
adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
}
+77
View File
@@ -636,6 +636,13 @@ pub struct Layer {
}
impl Layer {
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
/// transparency and position locked. Only the bottom layer of a document with them is the
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
pub fn has_background_traits(&self) -> bool {
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
}
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
Layer {
id: LayerId::fresh(),
@@ -875,6 +882,32 @@ pub struct Document {
pub variables: Variables,
/// Window › Timeline (None until a video timeline is created).
pub timeline: Option<Timeline>,
/// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so
/// a save writes it back the same way rather than at fixed defaults.
pub file_encoding: FileEncoding,
}
/// The encoder settings a document's file was opened or saved with: what File › Save and the
/// Save As defaults reuse for that format. Empty for a new document or a file without lossy
/// options. Qualities are on the codecs' 1–100 scale.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FileEncoding {
/// JPEG quality (estimated from the file's quantisation tables on open).
#[serde(skip_serializing_if = "Option::is_none")]
pub jpeg_quality: Option<u8>,
/// WebP: `Some(true)` lossless (VP8L), `Some(false)` lossy (VP8 at [`Self::webp_quality`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub webp_lossless: Option<bool>,
/// Lossy WebP quality.
#[serde(skip_serializing_if = "Option::is_none")]
pub webp_quality: Option<u8>,
}
impl FileEncoding {
pub fn is_empty(&self) -> bool {
*self == FileEncoding::default()
}
}
/// Where a layer lives in the tree: indices from the root down.
@@ -913,6 +946,7 @@ impl Document {
slices: Slices::default(),
variables: Variables::default(),
timeline: None,
file_encoding: FileEncoding::default(),
}
}
@@ -1013,6 +1047,25 @@ impl Document {
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
}
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
/// stores it without a transparency channel and transparency-protected; File › New and opaque
/// flat files create it the same way).
pub fn background(&self) -> Option<&Layer> {
self.layers.first().filter(|l| Layer::has_background_traits(l))
}
/// [`Self::background`], mutably.
pub fn background_mut(&mut self) -> Option<&mut Layer> {
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
}
/// Whether `id` is the document's Background layer ([`Self::background`]).
pub fn is_background(&self, id: LayerId) -> bool {
self.background().is_some_and(|b| b.id == id)
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
@@ -1178,6 +1231,30 @@ mod tests {
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
}
/// The Background is the locked bottom pixel layer, whatever it is called.
#[test]
fn background_is_structural_not_named() {
let mut d = doc();
let id = d.layers[0].id;
assert!(d.is_background(id));
for name in ["Hintergrund", "背景", "Arrière-plan"] {
d.layers[0].name = name.into();
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
}
// A layer called "Background" without the locks, or above the bottom, is not it.
let mut top = Layer::raster("Background", d.pixel_format());
top.locks.transparency = true;
top.locks.position = true;
let top_id = top.id;
d.layers.push(top);
assert!(!d.is_background(top_id));
d.layers[0].locks.position = false;
assert!(d.background().is_none());
d.layers[0].locks.position = true;
d.layers[0].clipped = true;
assert!(d.background_mut().is_none());
}
#[test]
fn max_group_depth_counts_nesting_without_recursing() {
let mut d = doc();
+17
View File
@@ -5,6 +5,13 @@
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
pub const MAX_EDGE: u32 = 30_000;
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
/// (depth and colour model are the pattern's own, as in Photoshop).
#[derive(Clone, Debug, PartialEq)]
@@ -57,6 +64,16 @@ impl Pattern {
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
/// Pixels in the tile (`width × height`, never overflowing).
pub fn pixel_count(&self) -> u64 {
u64::from(self.width) * u64::from(self.height)
}
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
pub fn is_paintable(&self) -> bool {
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
}
}
/// Display form of a stored pattern name (see [`Pattern::display_name`]).
+40
View File
@@ -1224,4 +1224,44 @@ mod tests {
assert!(bad.actions.list.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter,
/// a selection fill and styled text all act on the document the replay runs in.
#[test]
fn a_recorded_edit_run_replays_on_a_second_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
s.execute("actions.record", json!({"name": "Studio"})).unwrap();
s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap();
s.execute("type.setStyle", json!({"fauxBold": true})).unwrap();
let stopped = s.execute("actions.stop", json!({})).unwrap();
let steps = stopped["steps"].as_u64().unwrap();
assert!(steps >= 7, "the recording keeps every family: {stopped}");
// The second document: a different size, background and name. The replay must act here.
s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap();
assert!(played.get("failed").is_none(), "replay: {played}");
assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}");
let d = &s.active().unwrap().doc;
assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document");
assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed");
assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black");
let last = d.layers.last().unwrap();
match &last.content {
photocraft_doc::LayerContent::Text(t) => {
assert_eq!(t.text, "Hi", "the styled text replayed");
assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed");
}
other => panic!("expected the recorded type layer, got {other:?}"),
}
// The first document is untouched.
let first = &s.documents()[0].doc;
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
}
}
+1
View File
@@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
*active = Some(bg.id);
doc.layers = vec![bg];
+1 -1
View File
@@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"angle": 0.0}));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
compound(s, "Straighten", |s| {
+7 -8
View File
@@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::extra_cmds::is_background;
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.new.backgroundFromLayer";
/// The document's Background layer, if it has one (always the bottom layer).
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
doc.background().map(|l| l.id)
}
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
@@ -155,7 +154,7 @@ mod tests {
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
let l = &doc(&s).layers[0];
assert!(is_background(l) && l.name == "Background", "{depth}");
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
assert_eq!(s.active().unwrap().active_layer, Some(a));
// Its pixels stay; transparency takes the background colour (blue).
let red = pixel(&s, 3, 3);
@@ -170,10 +169,10 @@ mod tests {
assert!(s.undo());
assert_eq!(order(&s), [l0, a, b], "{depth}");
let l = doc(&s).layer(a).unwrap();
assert!(!is_background(l) && l.name == "A");
assert!(!doc(&s).is_background(l.id) && l.name == "A");
assert!(s.redo());
assert_eq!(order(&s), [a, l0, b], "{depth}");
assert!(is_background(&doc(&s).layers[0]));
assert!(doc(&s).is_background(doc(&s).layers[0].id));
}
}
@@ -191,7 +190,7 @@ mod tests {
assert_eq!(d.layers[1].id, l0);
assert_eq!(d.layers[2].id, a);
let bg = &d.layers[0];
assert!(is_background(bg) && !bg.visible, "visibility is kept");
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
assert_eq!(bg.opacity, 1.0);
}
@@ -211,7 +210,7 @@ mod tests {
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
// A group or an adjustment layer can't become the Background.
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
@@ -220,7 +219,7 @@ mod tests {
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
assert!(!s.is_enabled(CMD));
assert!(s.execute(CMD, json!({})).is_err());
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
}
#[test]
+23 -28
View File
@@ -415,8 +415,9 @@ pub struct LiveStroke {
symmetry: Option<crate::symmetry_cmds::PaintingSymmetry>,
mirrors: Vec<StrokeRenderer>,
mirror_distinct: Vec<bool>,
/// Restore passes separately; their union may span an otherwise untouched document.
symmetry_regions: Vec<Rect>,
/// What the symmetric preview drew last, once a mirror differs from the stroke, and how many
/// passes it merged (a mirror joining redraws every tile).
union_live: Option<(photocraft_paint::UnionLive, usize)>,
pre: Surface,
sel: Option<Surface>,
lock: bool,
@@ -508,7 +509,7 @@ impl LiveStroke {
symmetry,
mirrors,
mirror_distinct,
symmetry_regions: Vec::new(),
union_live: None,
pre,
sel,
lock,
@@ -567,33 +568,27 @@ impl LiveStroke {
}
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
if self.mirror_distinct.iter().any(|d| *d) {
// Merge every finished pass once, both here and on commit, including shared axes.
// The stroke and its distinct mirrors composite as one stroke, as the commit
// merges them; only the tiles that changed are redrawn.
let old = self.tail;
let mut original_preview = self.renderer.clone();
original_preview.finish();
let copies: Vec<_> = self
.mirrors
.iter()
.zip(&self.mirror_distinct)
.filter(|(_, d)| **d)
.map(|(r, _)| {
let mut r = r.clone();
r.finish();
r
})
let passes = 1 + self.mirror_distinct.iter().filter(|d| **d).count();
let (mut live, full, mut dmg) = match self.union_live.take() {
Some((live, merged)) => (live, merged != passes, Rect::EMPTY),
None => {
// Leaving the single-pass preview: take its smoothing tail back first.
if !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
(photocraft_paint::UnionLive::default(), true, old)
}
};
let mut renderers: Vec<&mut StrokeRenderer> = std::iter::once(&mut self.renderer)
.chain(self.mirrors.iter_mut().zip(&self.mirror_distinct).filter(|(_, d)| **d).map(|(r, _)| r))
.collect();
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
if self.symmetry_regions.is_empty() && !old.is_empty() {
surf.write_region(old, &self.pre.read_region(old));
}
for region in &self.symmetry_regions {
surf.write_region(*region, &self.pre.read_region(*region));
}
self.symmetry_regions =
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
self.tail = bounds;
return Ok(damage.union(&bounds));
dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock));
self.tail = old.union(&live.bounds());
self.union_live = Some((live, passes));
return Ok(dmg);
}
let mut dmg = Rect::EMPTY;
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
+47 -1
View File
@@ -2,11 +2,15 @@
//! has no menu item for them but lists them in Edit › Keyboard Shortcuts › Tools ("Decrease Brush
//! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound,
//! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush.
//!
//! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean
//! other things with other tools, so the shell gives them to it only for the painting tools
//! (`move_mods::arrow_keys`).
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always};
use crate::{Result, Session};
use crate::{EngineError, Result, Session};
/// Photoshop's largest brush diameter, in pixels.
pub const MAX_SIZE: f32 = 5000.0;
@@ -35,6 +39,23 @@ fn hardness(s: &mut Session, up: bool) -> Result<Value> {
crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } }))
}
/// The brush angle turned by `degrees` (positive is anticlockwise, as the angle field counts),
/// wrapped into Photoshop's -180..=180.
fn turned(angle: f32, degrees: f32) -> f32 {
let a = (angle + degrees).rem_euclid(360.0);
if a > 180.0 { a - 360.0 } else { a }
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let by = p
.get("degrees")
.and_then(Value::as_f64)
.filter(|d| d.is_finite() && d.abs() <= 360.0)
.ok_or_else(|| EngineError::BadParams { cmd: "tools.rotateBrush".into(), msg: "`degrees` must be a number from -360 to 360".into() })?;
let angle = turned(s.tools.brush.angle, by as f32);
crate::brush_cmds::set_brush(s, &json!({ "brush": { "angle": angle } }))
}
macro_rules! key_cmd {
($id:literal, $label:literal, $sc:literal, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: Some($sc), params: "{}", enabled: always, run: $run, journal: true }
@@ -47,6 +68,16 @@ pub fn specs() -> Vec<CommandSpec> {
key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)),
key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)),
key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)),
CommandSpec {
id: "tools.rotateBrush",
label: "Rotate Brush Tip",
menu: &[],
shortcut: None,
params: r##"{"degrees":-360..360 (positive anticlockwise; ← / → while painting send 1, ⇧ 15)}"##,
enabled: always,
run: rotate,
journal: true,
},
]
}
@@ -74,6 +105,21 @@ mod tests {
assert_eq!(hardness_step(0.0, false), 0.0);
}
#[test]
fn rotate_brush_turns_the_tip_and_wraps_at_180() {
assert_eq!(turned(0.0, 1.0), 1.0);
assert_eq!(turned(179.0, 15.0), -166.0);
assert_eq!(turned(-175.0, -15.0), 170.0);
assert_eq!(turned(180.0, 0.0), 180.0);
let mut s = Session::new();
s.execute("tools.rotateBrush", json!({"degrees": -15})).unwrap();
assert_eq!(s.tools.brush.angle, -15.0);
for bad in [json!({}), json!({"degrees": "x"}), json!({"degrees": 1e9}), json!(null)] {
assert!(s.execute("tools.rotateBrush", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.tools.brush.angle, -15.0);
}
#[test]
fn commands_step_the_session_brush_and_journal() {
let mut s = Session::new();
+191 -2
View File
@@ -4,7 +4,9 @@
//! Groups are the panel's top-level folders ([`BrushPreset::group`]); a group can hold nested
//! folders ([`BrushPreset::folder`], a path inside the group, e.g. the folders of an imported
//! `.abr` file). The group commands take an optional `folder` path to act on a nested folder
//! instead of the whole group. A folder exists while a preset is in it (or below it).
//! instead of the whole group, and `moveGroup` can move a group or folder `into` another folder
//! or group, or out to the top level (dragging a folder onto a folder in Photoshop's panel). A
//! folder exists while a preset is in it (or below it).
//!
//! The library order is the panel order: groups appear in order of their first preset, and a
//! group's folders and presets in order of their first preset too. Every
@@ -247,9 +249,116 @@ fn move_folder(s: &mut Session, p: &Value, g: String, f: Vec<String>, cmd: &str)
Ok(json!({ "group": g, "folder": f, "index": at }))
}
/// Where `into` puts a folder: `Some(None)` = the top level (it becomes a group), `Some(Some((group,
/// folder)))` = inside that folder of that group (`[]`: directly in the group). `None` without `into`.
type Dest = Option<(String, Vec<String>)>;
fn into_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Dest>> {
let into = match p.get("into") {
None | Some(Value::Null) => return Ok(None),
Some(v @ Value::Object(_)) => v,
Some(_) => return Err(bad(cmd, "`into` must be an object {group?, folder?}")),
};
let Some(g) = group_param(into, "group", cmd)? else {
if folder_param(into, cmd)?.is_some_and(|f| !f.is_empty()) {
return Err(bad(cmd, "`into.folder` needs `into.group`"));
}
return Ok(Some(None));
};
group_block(s, &g, cmd)?;
let f = folder_param(into, cmd)?.unwrap_or_default();
if !s.tools.presets.iter().any(|x| in_folder(x, &g, &f)) {
return Err(bad(cmd, format!("no folder `{}` in brush preset group `{g}`", f.join("/"))));
}
Ok(Some(Some((g, f))))
}
/// Move folder `f` of group `g` (the whole group when `f` is empty) into `dest`, keeping its
/// content and the order of its presets: Photoshop's drag of a folder onto another folder. It
/// lands at the end of the destination, or before the sibling folder (group at the top level)
/// named by `before`.
fn move_into(s: &mut Session, p: &Value, g: String, f: Vec<String>, dest: Dest, cmd: &str) -> Result<Value> {
if p.get("index").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`index` can't be combined with `into` (use `before`)"));
}
let leaf = match f.last() {
Some(l) => l.clone(),
None if g.is_empty() => return Err(bad(cmd, "the ungrouped presets are not a folder that can be moved")),
None => g.clone(),
};
let moving = |x: &BrushPreset| in_folder(x, &g, &f);
let rest_of = |x: &BrushPreset| x.folder.get(f.len()..).unwrap_or_default().to_vec();
let before = match p.get("before") {
None | Some(Value::Null) => None,
Some(Value::String(b)) => Some(b.trim().to_string()),
Some(_) => return Err(bad(cmd, "`before` must be a folder name")),
};
// The destination's own folders and groups, without the one being moved.
let others: Vec<BrushPreset> = s.tools.presets.iter().filter(|x| !moving(x)).cloned().collect();
let (new_group, new_prefix, siblings) = match &dest {
None => {
if f.is_empty() {
return Err(bad(cmd, "`into`: the group is already at the top level"));
}
(leaf.clone(), Vec::new(), group_order(&others))
}
Some((dg, df)) => {
if *dg == g && df.starts_with(&f) {
return Err(bad(cmd, "`into` names the folder being moved or one of its own folders"));
}
let mut prefix = df.clone();
prefix.push(leaf.clone());
(dg.clone(), prefix, subfolder_order(&others, dg, df))
}
};
if siblings.contains(&leaf) {
return Err(bad(cmd, format!("a folder named `{leaf}` already exists there")));
}
if let Some(b) = &before
&& !siblings.contains(b)
{
return Err(bad(cmd, format!("`before`: no folder named `{b}` there")));
}
let deepest = s.tools.presets.iter().filter(|x| moving(x)).map(|x| new_prefix.len() + rest_of(x).len()).max().unwrap_or(0);
if deepest > MAX_FOLDER_DEPTH {
return Err(bad(cmd, format!("folders can be nested at most {MAX_FOLDER_DEPTH} deep")));
}
let lib = std::mem::take(&mut s.tools.presets);
let (mut block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| in_folder(x, &g, &f));
for x in &mut block {
let tail = rest_of(x);
x.group.clone_from(&new_group);
x.folder = new_prefix.iter().cloned().chain(tail).collect();
x.builtin = false;
}
let pos = match (&dest, &before) {
(None, Some(b)) => rest.iter().position(|x| x.group == *b),
(None, None) => None,
(Some((dg, df)), Some(b)) => {
let sib: Vec<String> = df.iter().cloned().chain(std::iter::once(b.clone())).collect();
rest.iter().position(|x| in_folder(x, dg, &sib))
}
(Some((dg, df)), None) => rest.iter().rposition(|x| in_folder(x, dg, df)).map(|k| k + 1),
};
let tail = rest.split_off(pos.unwrap_or(rest.len()).min(rest.len()));
rest.extend(block);
rest.extend(tail);
s.tools.presets = rest;
s.brush_presets_changed();
let folder = new_prefix.get(..new_prefix.len().saturating_sub(1)).unwrap_or_default();
let at = match &dest {
None => group_order(&s.tools.presets).iter().position(|x| *x == new_group),
Some(_) => subfolder_order(&s.tools.presets, &new_group, folder).iter().position(|x| *x == leaf),
};
Ok(json!({ "group": new_group, "folder": new_prefix, "index": at.unwrap_or(0) }))
}
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.moveGroup";
let (g, f) = target_folder(s, p, cmd)?;
if let Some(dest) = into_param(s, p, cmd)? {
return move_into(s, p, g, f, dest, cmd);
}
if !f.is_empty() {
return move_folder(s, p, g, f, cmd);
}
@@ -348,7 +457,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"brush.presets.moveGroup",
"Move Brush Group",
r##"{"group":string,"folder":[name]? (move this nested folder among its sibling folders instead),"before":group? (or sibling folder name) | "index":n? (group or sibling folder position)=end} → {group, folder?, index}"##,
r##"{"group":string,"folder":[name]? (move this nested folder instead),"into":{"group":string?,"folder":[name]?}? (move it inside that folder of that group, [] = directly in the group; no group = out to the top level as a group of its own; the folder keeps its content),"before":group? (or sibling folder name, in its new place with `into`) | "index":n? (group or sibling folder position, without `into`)=end} → {group, folder?, index}"##,
move_group
),
spec!(
@@ -572,4 +681,84 @@ mod tests {
assert_eq!(s.tools.presets, before);
s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "index": u64::MAX})).unwrap();
}
fn paths(s: &Session) -> Vec<String> {
s.tools.presets.iter().filter(|x| !x.builtin).map(|x| format!("{}:{}:{}", x.group, x.folder.join("/"), x.name)).collect()
}
/// `with_folders` plus group "Other" holding O1, and folder Box (B1) in it.
fn with_two_groups() -> Session {
let mut s = with_folders();
for (n, f) in [("O1", &[][..]), ("B1", &["Box"][..])] {
let folder = f.iter().map(|x| x.to_string()).collect();
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Other".into(), folder });
}
s
}
#[test]
fn folders_move_into_folders_and_groups() {
let mut s = with_two_groups();
// A nested folder into a sibling folder: its subtree follows, order kept.
let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}})).unwrap();
assert_eq!(r, json!({"group": "Set", "folder": ["Ink", "Fine", "Dry"], "index": 0}));
assert_eq!(folder_of(&s, "D1"), ["Ink", "Fine", "Dry"]);
// Into another group, before one of its folders.
let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Other"}, "before": "Box"})).unwrap();
assert_eq!(r["folder"], json!(["Ink"]));
assert_eq!(paths(&s), ["Set::S0", "Other::O1", "Other:Ink:I1", "Other:Ink/Fine:F1", "Other:Ink/Fine/Dry:D1", "Other:Ink:I2", "Other:Box:B1"]);
// Out to the top level: the folder becomes a group of its own.
let r = s.execute("brush.presets.moveGroup", json!({"group": "Other", "folder": ["Ink", "Fine"], "into": {}})).unwrap();
assert_eq!(r["group"], "Fine");
assert_eq!(folder_of(&s, "D1"), ["Dry"]);
assert_eq!(photocraft_paint::presets::find(&s.tools.presets, "F1").unwrap().group, "Fine");
// A whole group into another group's folder.
s.execute("brush.presets.moveGroup", json!({"group": "Fine", "into": {"group": "Set"}})).unwrap();
assert!(names(&s, "Fine").is_empty());
assert_eq!(folder_of(&s, "D1"), ["Fine", "Dry"]);
assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Fine"]);
}
#[test]
fn bad_moves_into_folders_fail_without_changes() {
let mut s = with_two_groups();
// A chain 31 deep under Set/Deep.
let deep: Vec<String> = std::iter::once("Deep".to_string()).chain((0..30).map(|i| format!("d{i}"))).collect();
s.tools.presets.push(BrushPreset { name: "Z".into(), brush: Default::default(), builtin: false, group: "Set".into(), folder: deep.clone() });
s.tools.presets.push(BrushPreset { name: "Y".into(), brush: Default::default(), builtin: false, group: "Other".into(), folder: vec!["Dry".into()] });
let before = s.tools.presets.clone();
let deep_json = json!(deep);
for p in [
// Into itself or its own descendant.
json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": ["Ink"]}}),
json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}}),
json!({"group": "Set", "into": {"group": "Set", "folder": ["Dry"]}}),
json!({"group": "Set", "into": {"group": "Set"}}),
// A group already at the top level; the ungrouped presets.
json!({"group": "Set", "into": {}}),
// A name clash at the destination.
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other"}}),
json!({"group": "Other", "folder": ["Box"], "into": {"group": "Set", "folder": ["Ink"]}, "before": "Nope"}),
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Set", "folder": ["Ink"]}, "index": 0}),
// Missing destinations and bad shapes.
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Nope"}}),
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other", "folder": ["Nope"]}}),
json!({"group": "Set", "folder": ["Dry"], "into": {"folder": ["Box"]}}),
json!({"group": "Set", "folder": ["Dry"], "into": "Other"}),
json!({"group": "Set", "folder": ["Dry"], "into": {"group": 5}}),
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other"}, "before": 3}),
// Deeper than MAX_FOLDER_DEPTH once moved (Ink/Fine is 2 deep: 2 + 31 > 32).
json!({"group": "Set", "folder": ["Deep"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}}),
json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": deep_json}}),
] {
assert!(s.execute("brush.presets.moveGroup", p.clone()).is_err(), "{p}");
}
assert_eq!(s.tools.presets, before);
// Ink/Fine into Set itself: a clash with nothing, so it moves up a level.
s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "into": {"group": "Set"}})).unwrap();
assert_eq!(folder_of(&s, "F1"), ["Fine"]);
// An ungrouped preset's group can't move.
s.tools.presets.push(BrushPreset { name: "U".into(), brush: Default::default(), builtin: false, group: String::new(), folder: Vec::new() });
assert!(s.execute("brush.presets.moveGroup", json!({"group": "", "into": {"group": "Set"}})).is_err());
}
}
+5 -1
View File
@@ -66,6 +66,10 @@ fn retag_text(dpi: f32, t: &mut TextLayer) {
if t.psd_raw.is_none() {
return;
}
if crate::type_cmds::keeps_pixels(t) {
crate::type_cmds::retag_transform(t);
return;
}
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let layout = eng.layout(t, dpi);
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
@@ -187,7 +191,7 @@ pub(crate) fn refresh(doc: &mut Document, what: Refresh) {
match &mut l.content {
LayerContent::Group(g) => rec(snapshot, &mut g.children, what),
LayerContent::Shape(sh) => crate::vector_cmds::refresh_shape(snapshot, sh),
LayerContent::Text(t) if what == Refresh::All => crate::type_cmds::refresh(snapshot, t),
LayerContent::Text(t) if what == Refresh::All => crate::type_cmds::refresh_geometry(snapshot, t),
LayerContent::Smart(_) if what == Refresh::All => {
let _ = crate::smart_cmds::refresh_layer(snapshot, l);
}
+23 -1
View File
@@ -608,7 +608,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<st
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
pub fn follows_target(id: &str) -> bool {
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
routed(id) || id == "select.float"
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
routed(id) || id == "select.float" || id == "layer.translate"
}
/// Commands whose target follows the Channels panel.
@@ -1125,11 +1126,15 @@ fn delete_channel(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "missing `channel`")),
},
};
// `edit` validates the view against the shorter channel list before we can remap it.
// Keep the original indices and eyes so a surviving final channel is not discarded.
let view = s.active().ok_or(EngineError::NoDocument)?.channel_view.clone();
s.edit("Delete Channel", |doc, _| {
doc.channels.remove(i);
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.channel_view = view;
let v = &mut st.channel_view;
if i < v.alpha_visible.len() {
v.alpha_visible.remove(i);
@@ -1803,6 +1808,16 @@ pub fn specs() -> Vec<CommandSpec> {
journal: false,
},
spec!("channel.target.composite", "Target Composite Channel", [], Some("Cmd+2"), "{}", has_doc, |s, _| target_slot(s, 2)),
// ⌘\ targets the active layer's mask, as clicking its thumbnail does.
spec!("channel.target.layerMask", "Target Layer Mask", [], Some("Cmd+\\"), "{}", has_layer_mask, |s, _| target_cmd(
s,
&serde_json::json!({"channel": "mask"})
)),
// ⌘⌥2 loads the image's luminosity as a selection (⌘-click on the composite channel).
spec!("select.loadLuminosity", "Load Luminosity", [], Some("Cmd+Alt+2"), "{}", has_doc, |s, _| load_selection(
s,
&serde_json::json!({"channel": "composite"})
)),
spec!("channel.target.slot3", "Target Channel 3", [], Some("Cmd+3"), "{}", slot_ok::<3>, |s, _| target_slot(s, 3)),
spec!("channel.target.slot4", "Target Channel 4", [], Some("Cmd+4"), "{}", slot_ok::<4>, |s, _| target_slot(s, 4)),
spec!("channel.target.slot5", "Target Channel 5", [], Some("Cmd+5"), "{}", slot_ok::<5>, |s, _| target_slot(s, 5)),
@@ -1813,6 +1828,13 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Target Layer Mask: the active layer has a mask.
fn has_layer_mask(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("no active layer")?;
layer.mask.as_ref().map(|_| ()).ok_or_else(|| "the active layer has no layer mask".to_string())
}
fn slot_ok<const N: usize>(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
let colors = color_count(&d.doc);
+77
View File
@@ -168,6 +168,56 @@ fn channel_management_and_view_state() {
assert_eq!(doc(&s).channels.len(), 3);
}
#[test]
fn deleting_an_earlier_channel_keeps_the_last_channel_targeted() {
for depth in [8, 16, 32] {
let mut s = session_depth(depth);
s.execute("channel.new", json!({"name": "Remove", "fill": "black"})).unwrap();
s.execute("channel.new", json!({"name": "Keep", "fill": "white"})).unwrap();
let layer_before = px(&s, 5, 5);
s.execute("channel.delete", json!({"channel": "Remove"})).unwrap();
assert_eq!(doc(&s).channels[0].name, "Keep");
let view = &s.active().unwrap().channel_view;
assert_eq!(view.target, ChannelTarget::Alpha(0), "the surviving target follows its new index");
assert!(view.alpha_shown(0));
assert_eq!(view.visible_colors(3), 0, "keep the channel-only view");
s.execute("image.adjustments.invert", json!({})).unwrap();
assert_eq!(chan(&s, 0, 5, 5), 0.0, "implicit pixel edits still target Keep");
assert_eq!(px(&s, 5, 5), layer_before, "the layer must not be edited instead");
assert!(s.undo());
assert_eq!(chan(&s, 0, 5, 5), 1.0);
assert_eq!(px(&s, 5, 5), layer_before);
assert!(s.redo());
assert_eq!(chan(&s, 0, 5, 5), 0.0);
assert_eq!(px(&s, 5, 5), layer_before);
}
}
#[test]
fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() {
for target_index in 0..3 {
let mut s = session();
for name in ["First", "Middle", "Last"] {
s.execute("channel.new", json!({"name": name})).unwrap();
}
s.execute("channel.target", json!({"channel": target_index})).unwrap();
for (channel, visible) in [false, false, true].into_iter().enumerate() {
s.execute("channel.setVisible", json!({"channel": channel, "visible": visible})).unwrap();
}
s.execute("channel.delete", json!({"channel": "Middle"})).unwrap();
let view = &s.active().unwrap().channel_view;
let expected = match target_index {
0 => ChannelTarget::Alpha(0),
1 => ChannelTarget::Composite,
_ => ChannelTarget::Alpha(1),
};
assert_eq!(view.target, expected, "old target {target_index}");
assert!(!view.alpha_shown(0));
assert!(view.alpha_shown(1), "Last keeps its eye when Middle is removed");
}
}
#[test]
fn every_colour_channel_can_hide_while_alpha_stays() {
let mut s = session();
@@ -741,6 +791,33 @@ fn clear_with_an_alpha_channel_or_quick_mask_targeted_fills_it_not_the_layer() {
assert_eq!(px(&s, 5, 5), layer, "the layer is untouched in Quick Mask mode");
}
#[test]
fn command_backslash_targets_the_layer_mask_and_command_2_the_composite() {
let mut s = session();
s.execute("layer.new.layer", json!({})).unwrap();
assert!(!s.is_enabled("channel.target.layerMask"), "no mask yet");
assert!(s.execute("channel.target.layerMask", json!({})).is_err());
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("channel.target.composite", json!({})).unwrap();
assert_eq!(crate::commands::find("channel.target.layerMask").and_then(|c| c.shortcut), Some("Cmd+\\"));
s.execute("channel.target.layerMask", json!({})).unwrap();
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::LayerMask);
s.execute("channel.target.composite", json!({})).unwrap();
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite);
}
#[test]
fn command_alt_2_loads_the_images_luminosity() {
let mut s = session();
rect(&mut s, 0, 0, 20, 20, "replace");
s.execute("edit.fill", json!({"contents": "black"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
assert_eq!(crate::commands::find("select.loadLuminosity").and_then(|c| c.shortcut), Some("Cmd+Alt+2"));
s.execute("select.loadLuminosity", json!({})).unwrap();
assert!(sel_at(&s, 5, 5) < 0.01, "black: unselected");
assert!(sel_at(&s, 30, 5) > 0.99, "white: selected");
}
#[test]
fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() {
for depth in [8, 16, 32] {
+59 -3
View File
@@ -276,18 +276,22 @@ impl ColorState {
if !mismatch || linear_hdr {
return merge(base, json!({"action": "kept"}));
}
// An input-only profile can't be the document's space: nothing could be
// composited into it (#3876). Preserve converts it to the working space instead.
let input_only = !emb.can_be_destination();
let action = match policy {
Policy::Preserve => "kept",
Policy::Preserve if !input_only => "kept",
Policy::Off => {
doc.icc_profile = None;
"discarded"
}
Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
Policy::Preserve | Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
Ok(()) => "converted",
Err(_) => "kept",
},
};
merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}))
let report = merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}));
if input_only { merge(report, json!({"inputOnly": true})) } else { report }
}
None => {
// Untagged files are assumed to be in the working space; tag them when it is not
@@ -1397,6 +1401,58 @@ mod settings_tests {
assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3"));
}
/// Display P3 as an input profile with only an AToB table, like Apple's Wide Color Sharing
/// Profile embedded in iPhone photos.
fn input_only_p3() -> Profile {
let p3 = Builtin::DisplayP3.profile();
let t = Transform::new(p3, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
let clut = photocraft_cms::Clut::sample(vec![9; 3], 3, |inp, out| t.eval(inp, out));
let ident = || photocraft_cms::pipeline::Stage::Curves(vec![photocraft_cms::Curve::Identity; 3]);
let lut = photocraft_cms::profile::Lut {
kind: photocraft_cms::profile::LutKind::Ab,
inputs: 3,
outputs: 3,
stages: vec![ident(), photocraft_cms::pipeline::Stage::Clut(clut), ident()],
};
let mut p = p3.clone();
p.class = photocraft_cms::ProfileClass::Input;
p.pcs = photocraft_cms::Pcs::Lab;
p.description = "Input-only P3".into();
(p.matrix, p.trc) = (None, None);
p.a2b = [Some(lut), None, None];
p.b2a = [None, None, None];
p.with_encoded_bytes()
}
#[test]
fn input_only_profiles_are_converted_on_open() {
// #3876: an iPhone photo's input-only profile was kept as the document's space, so
// compositing anything into it failed with "RGB profile without matrix/TRC or BToA tags".
let input = input_only_p3();
assert!(Builtin::DisplayP3.profile().can_be_destination());
assert!(!input.can_be_destination());
let mut s = Session::new();
let mut d = tagged("srgb");
d.icc_profile = Some(input.to_bytes());
let (photo, r) = s.open_document(d, None);
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool(), r["ask"].as_bool()), (Some("converted"), Some(true), Some(true)));
let doc = &s.active().unwrap().doc;
assert!(desc(doc).unwrap().starts_with("sRGB"));
// Converted from P3, not reinterpreted as sRGB.
assert!((doc.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01);
let (other, _) = s.open_document(tagged("srgb"), None);
s.execute("layer.copyToDocument", json!({"document": photo, "source": other})).unwrap();
// Off still discards the profile, and profiles that can be a destination are still kept.
s.execute("edit.colorSettings", json!({"policyRgb": "off"})).unwrap();
let mut d = tagged("srgb");
d.icc_profile = Some(input.to_bytes());
let (_, r) = s.open_document(d, None);
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool()), (Some("discarded"), Some(true)));
s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap();
let (_, r) = s.open_document(tagged("display-p3"), None);
assert_eq!((r["action"].as_str(), r.get("inputOnly")), (Some("kept"), None));
}
/// A little-endian EXIF block whose Exif IFD holds ColorSpace = `space`.
fn exif_color_space(space: u16) -> Vec<u8> {
let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec();
+2 -2
View File
@@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
if locks.pixels || locks.all {
return Err(format!("the layer \"{}\" is locked", l.name));
}
if locks.transparency && !crate::extra_cmds::is_background(l) {
if locks.transparency && !d.doc.is_background(l.id) {
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
}
Ok(())
@@ -217,7 +217,7 @@ mod tests {
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&s), "Background");
let d = s.active().unwrap();
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
assert!(d.doc.is_background(d.active_layer.unwrap()));
}
#[test]
+55 -15
View File
@@ -136,6 +136,19 @@ pub fn command_specs() -> &'static [CommandSpec] {
&registry().specs
}
/// One `command.list` / `engine.commands` row for `c`, given why it can't run now (`None` = it can).
/// `disabledReason` is that message; `needsDocument` is true when the reason is that no document is
/// open (#3664), so an agent can tell "open or create a document first" from a precondition that a
/// document alone won't meet (a selection, an empty clipboard).
pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option<String>) -> Value {
let needs_document =
s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p)));
json!({
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params,
"enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document,
})
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
let registry = registry();
registry.by_id.get(id).and_then(|&index| registry.specs.get(index))
@@ -761,7 +774,7 @@ fn build() -> Vec<CommandSpec> {
below.push(idx - 1);
let lower = doc.layer_at(&below).ok_or(EngineError::NoLayer(id))?.clone();
let upper = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone();
let merged = pixels::merge_down(doc.bounds(), &lower, &upper, doc.pixel_format());
let merged = pixels::merge_down(doc, &lower, &upper);
doc.remove(id);
*doc.layer_at_mut(&below).ok_or(EngineError::NoLayer(lower.id))? = merged;
*active = Some(lower.id);
@@ -769,6 +782,13 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("layer.mergeGroup", "Merge Group", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
if !s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) {
return Err(EngineError::Other("Merge Group needs a group".into()));
}
merge_group(s, id)
}),
cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| {
s.edit("Flatten Image", |doc, active| {
let fmt = doc.pixel_format();
@@ -878,7 +898,7 @@ fn build() -> Vec<CommandSpec> {
"Move Layer",
[],
None,
r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##,
r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
has_layer,
crate::layer_multi_cmds::translate
),
@@ -1014,18 +1034,7 @@ fn build() -> Vec<CommandSpec> {
shortcut: None,
params: "{}",
enabled: always,
run: |s, _| {
Ok(Value::Array(
command_specs()
.iter()
.map(|c| {
json!({
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok(),
})
})
.collect(),
))
},
run: |s, _| Ok(Value::Array(command_specs().iter().map(|c| list_entry(s, c, (c.enabled)(s).err())).collect())),
journal: false,
},
CommandSpec {
@@ -1416,7 +1425,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
let from_background = doc.is_background(id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if from_background {
@@ -1425,6 +1434,37 @@ pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
Ok(dup)
}
/// Layer › Merge Group (⌘E with a group selected, #3832): the group's visible contents, as they
/// show, become one pixel layer in its place, named after it. Like Photoshop, the layer keeps the
/// group's opacity, blend mode (Pass Through becomes Normal), clipping and locks; the group's mask
/// and style are applied to the pixels.
fn merge_group(s: &mut Session, id: LayerId) -> Result<Value> {
s.edit("Merge Group", |doc, active| {
let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone();
if !group.visible {
return Err(EngineError::Other("the group is hidden".into()));
}
let fmt = doc.pixel_format();
let fmt = photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true);
let mut solo = group.clone();
solo.opacity = 1.0;
solo.blend = photocraft_doc::BlendMode::Normal;
solo.clipped = false;
let mut tmp = doc.clone();
tmp.layers = vec![solo];
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None)));
merged.id = group.id;
merged.opacity = group.opacity;
merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend };
merged.clipped = group.clipped;
merged.locks = group.locks;
*doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = merged;
*active = Some(id);
Ok(())
})?;
Ok(json!({ "layer": id.0 }))
}
#[cfg(test)]
mod registry_tests {
use super::*;
+4 -12
View File
@@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
}
}
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
fn float_background(l: &mut Layer) {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
}
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
let locks = l.locks.union(group);
if locks.all || locks.pixels || (position && locks.position) {
@@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
let canvas = doc.bounds();
let selection = doc.selection.clone();
let group = crate::transform_cmds::group_locks(doc, id);
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
if moves {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
}
if moves {
float_background(l);
}
check_locks(l, group, moves)?;
let LayerContent::Raster(surf) = &mut l.content else {
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
+7 -6
View File
@@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
let mut copies = scratch_doc(&d.doc);
for id in ids {
let mut l = d.doc.layer(*id)?.clone();
if crate::extra_cmds::is_background(&l) {
if d.doc.is_background(*id) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
@@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
/// transparency, so there the area is filled with the background colour instead (Photoshop).
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let bg = doc.is_background(id);
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
if bg {
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
@@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
}
let lowest = visible[0];
let base = doc.layers[lowest].clone();
let is_background = base.name == "Background" && base.locks.transparency;
let is_background = lowest == 0 && doc.is_background(base.id);
// Composite of visible layers only (hidden ones stay where they are).
let mut solo = doc.clone();
solo.layers.retain(|l| l.visible);
@@ -1363,7 +1364,7 @@ mod tests {
let mut s = fresh();
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
}
@@ -1609,9 +1610,9 @@ mod tests {
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), 4);
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
assert!(!crate::extra_cmds::is_background(pasted));
assert!(!st.doc.is_background(pasted.id));
assert_eq!(pasted.locks, Default::default());
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
}
/// #2428: a smart object copied in one document pastes into another as a smart object, in
+5 -16
View File
@@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
}
fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
/// Photoshop converts the Background to a normal layer before erasing to transparency.
fn unlock_background(l: &mut Layer) {
if is_background(l) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
}
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
let locks = doc.effective_locks(l.id);
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
@@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let bg = s.tools.background;
let dmg = s.edit("Magic Eraser", |doc, _| {
let selection = doc.selection.clone();
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
// Photoshop converts the Background to a normal layer before erasing to transparency.
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
crate::extra_cmds::background_to_layer(doc, id);
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
@@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = pixels_locked(&d.doc, l) {
return Err(e);
}
if d.doc.effective_locks(id).transparency && !is_background(l) {
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
}
}
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Background Eraser", |doc, _| {
let selection = doc.selection.clone();
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
unlock_background(l);
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
})?;
+182 -12
View File
@@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
}
pub(crate) fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
fn has_background(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
}
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
@@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
}
@@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
fn layer_from_background(s: &mut Session) -> Result<Value> {
let id = s.edit("Layer From Background", |doc, active| {
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
unlock_background(l);
*active = Some(l.id);
Ok(l.id)
@@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
background_to_layer(doc, id);
}
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
unlock_background(l);
}
}
@@ -563,6 +566,35 @@ fn set_visible(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
Ok(Value::Null)
}
/// Layer › Hide Layers (⌘,): hide the targeted layers, or show them when every one is already
/// hidden, as Photoshop's item does (it reads Show Layers then), so ⌘, toggles.
fn hide_or_show(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let targets = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] };
let all_hidden = targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| !l.visible));
set_visible(s, p, all_hidden)
}
/// `/`: Lock Transparent Pixels on or off for the targeted layers, as the Layers panel's first lock
/// button does. With several layers targeted, any unlocked one means lock them all. The
/// Background, which can't take this lock, is left out.
fn toggle_lock_transparency(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let targets: Vec<LayerId> = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] };
let targets: Vec<LayerId> = targets.into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| !st.doc.is_background(l.id))).collect();
if targets.is_empty() {
return Err(EngineError::Other("the Background layer can't lock transparent pixels".into()));
}
let lock = !targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| l.locks.transparency));
s.edit(if lock { "Lock Transparent Pixels" } else { "Unlock Transparent Pixels" }, |doc, _| {
for id in &targets {
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.locks.transparency = lock;
}
Ok(())
})?;
Ok(json!({"locked": lock}))
}
/// Is the layer at `path` shown alone: it and its enclosing groups visible, every layer outside
/// it hidden (the layers inside a group keep their own visibility)?
fn shown_alone(doc: &Document, path: &[usize]) -> bool {
@@ -664,15 +696,42 @@ fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str
"shape" => {
s.execute("layer.rasterize.shape", json!({"layer": id.0, "coalesce": key}))?;
}
_ => {
let label = if kind == "fill" { "Rasterize Fill Content" } else { "Rasterize Smart Object" };
"smart" => {
s.coalesce_request = Some(key.to_string());
let r = s.edit(label, |doc, _| {
let r = s.edit("Rasterize Smart Object", |doc, _| {
let snapshot = doc.clone();
let mut snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?;
if matches!(snap.content, LayerContent::Smart(ref sm) if sm.cache.is_none()) {
let _ = crate::smart_cmds::refresh_layer(&snapshot, &mut snap);
}
let fmt = snapshot.pixel_format();
let surface = match &mut snap.content {
LayerContent::Smart(sm) => match sm.cache.take() {
Some(c) => c,
None => content_pixels(&snapshot, &snap),
},
_ => return Err(EngineError::Other(format!("layer {} is not a smart object", id.0))),
};
let surface = if surface.format() == fmt { surface } else { surface.convert(fmt) };
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.content = LayerContent::Raster(surface);
l.psd_blocks.retain(|(k, _)| k != b"SoLd" && k != b"SoLE" && k != b"PlLd");
Ok(())
});
s.coalesce_request = None;
r?;
}
_ => {
s.coalesce_request = Some(key.to_string());
let r = s.edit("Rasterize Fill Content", |doc, _| {
let snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?;
let px = content_pixels(doc, &snap);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.content = LayerContent::Raster(px);
l.fill_cache = None;
// The PSD fill or placed-layer blocks no longer describe the layer: kept, they
// would reopen it as a fill layer or smart object and drop these pixels.
l.psd_blocks.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl" | b"SoLd" | b"PlLd" | b"SoLE"));
Ok(())
});
s.coalesce_request = None;
@@ -910,8 +969,25 @@ pub fn specs() -> Vec<CommandSpec> {
},
ungroup
),
spec!("layer.hideLayers", "Hide Layers", &["Layer"], Some("Cmd+,"), r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, false)),
spec!(
"layer.hideLayers",
"Hide Layers",
&["Layer"],
Some("Cmd+,"),
r##"{"layer":id?} (shows them instead when every targeted layer is already hidden, so ⌘, toggles)"##,
has_layer,
hide_or_show
),
spec!("layer.showLayers", "Show Layers", &[], None, r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, true)),
spec!(
"layer.toggleLockTransparency",
"Lock Transparent Pixels",
&[],
Some("/"),
r##"{"layer":id?} (toggles; several layers selected: locks them all unless every one is locked)"##,
has_layer,
toggle_lock_transparency
),
spec!(
"layer.showOnly",
"Show Only This Layer",
@@ -1490,6 +1566,48 @@ mod tests {
assert_eq!(doc(&s).layers[0].name, "Background");
}
#[test]
fn hide_layers_toggles_once_every_target_is_hidden() {
let mut s = session(8);
s.execute("layer.new.layer", json!({})).unwrap();
let b = active(&s).id;
let visible = |s: &Session| doc(s).layer(b).unwrap().visible;
// Select › All Layers leaves out the Background, as in Photoshop.
let all = |s: &Session| crate::layer_multi_cmds::selected(s).iter().map(|id| doc(s).layer(*id).unwrap().visible).collect::<Vec<_>>();
s.execute("layer.hideLayers", json!({})).unwrap();
assert!(!visible(&s));
s.execute("layer.hideLayers", json!({})).unwrap();
assert!(visible(&s), "⌘, again shows it");
// Several selected: any one still shown means hide them all; then they all come back.
s.execute("layer.hideLayers", json!({"layer": b.0})).unwrap();
s.execute("select.allLayers", json!({})).unwrap();
assert!(all(&s).len() > 1);
s.execute("layer.hideLayers", json!({})).unwrap();
assert!(all(&s).iter().all(|v| !v));
s.execute("layer.hideLayers", json!({})).unwrap();
assert!(all(&s).iter().all(|v| *v));
}
#[test]
fn slash_toggles_lock_transparent_pixels_but_not_on_the_background() {
let mut s = session(8);
let bg = doc(&s).layers[0].id;
assert!(doc(&s).is_background(bg));
s.execute("layer.new.layer", json!({})).unwrap();
let top = active(&s).id;
let locked = |s: &Session, id: LayerId| doc(s).layer(id).unwrap().locks.transparency;
assert_eq!(s.execute("layer.toggleLockTransparency", json!({})).unwrap()["locked"], true);
assert!(locked(&s, top));
s.execute("layer.toggleLockTransparency", json!({})).unwrap();
assert!(!locked(&s, top));
s.undo();
assert!(locked(&s, top), "one undoable step each");
let bg_locks = doc(&s).layer(bg).unwrap().locks;
assert!(s.execute("layer.toggleLockTransparency", json!({"layer": bg.0})).is_err());
assert_eq!(doc(&s).layer(bg).unwrap().locks, bg_locks, "the Background is left alone");
assert!(s.execute("layer.toggleLockTransparency", json!({"layer": 999_999})).is_err());
}
#[test]
fn layer_utilities() {
let mut s = session(8);
@@ -1561,4 +1679,56 @@ mod tests {
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert!(doc(&s).walk().iter().all(|(_, _, l)| !matches!(l.content, LayerContent::Fill(_))));
}
#[test]
fn rasterize_smart_object_layer() {
let mut s = session(8);
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
let px_before = pixel(&s, 5, 5);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
assert!(matches!(active(&s).content, LayerContent::Smart(_)));
s.execute("layer.rasterize.smartObject", json!({})).unwrap();
assert!(matches!(active(&s).content, LayerContent::Raster(_)));
let px_after = pixel(&s, 5, 5);
assert_eq!(px_after, px_before);
// Case 2: New layer above background
let mut s2 = session(8);
s2.execute("layer.new.layer", json!({})).unwrap();
s2.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s2.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
s2.execute("select.deselect", json!({})).unwrap();
let px_mid = pixel(&s2, 20, 20);
assert_eq!(px_mid[1], 1.0); // green
s2.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
assert!(matches!(active(&s2).content, LayerContent::Smart(_)));
s2.execute("layer.rasterize.smartObject", json!({})).unwrap();
assert!(matches!(active(&s2).content, LayerContent::Raster(_)));
let px_after_rast = pixel(&s2, 20, 20);
assert_eq!(px_after_rast, px_mid);
// Case 3: Shape layer converted to smart object then rasterized
let mut s3 = session(8);
s3.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#0000ff"})).unwrap();
let px_shape = pixel(&s3, 20, 20);
s3.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
assert!(matches!(active(&s3).content, LayerContent::Smart(_)));
s3.execute("layer.rasterize.layer", json!({})).unwrap();
assert!(matches!(active(&s3).content, LayerContent::Raster(_)));
let px_after_shape = pixel(&s3, 20, 20);
assert_eq!(px_after_shape, px_shape);
// Case 4: Transformed smart object
let mut s4 = session(8);
s4.execute("layer.new.layer", json!({})).unwrap();
s4.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s4.execute("edit.fill", json!({"contents": "color", "color": "#ffff00"})).unwrap();
s4.execute("select.deselect", json!({})).unwrap();
s4.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
// Move smart object by 10, 10
s4.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 10]})).unwrap();
s4.execute("layer.rasterize.smartObject", json!({})).unwrap();
assert!(matches!(active(&s4).content, LayerContent::Raster(_)));
assert_eq!(pixel(&s4, 30, 30), vec![1.0, 1.0, 0.0, 1.0]);
}
}
+15 -6
View File
@@ -285,6 +285,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
let save = save.into();
let mut opts =
photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() };
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
// lossy or lossless as it was), as Photoshop's Save does.
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
if let Some(q) = save.quality {
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
opts.encode.jpeg_quality = q;
@@ -1032,8 +1035,10 @@ fn has_live_effects(l: &Layer) -> bool {
l.effects.enabled && l.effects.items.iter().any(|e| e.enabled())
}
/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`.
fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool) -> Surface {
/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask`
/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them
/// live on the baked raster does not mask the pixels twice.
fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface {
let mut tmp = l.clone();
tmp.opacity = 1.0;
tmp.blend = photocraft_color::BlendMode::Normal;
@@ -1043,6 +1048,10 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool) -> Surfac
tmp.effects.items.clear();
tmp.fill_opacity = 1.0;
}
if !apply_mask {
tmp.mask = None;
tmp.vector_mask = None;
}
// Render over the union of the canvas and the content so off-canvas pixels survive.
let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas));
let area = if keep_effects { area.inflate(256) } else { area };
@@ -1063,12 +1072,12 @@ fn flatten_all_effects(s: &mut Session) -> Result<Value> {
if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) {
continue;
}
let px = bake(l, canvas, fmt, true);
// The pixel and vector masks stay on the layer (baking must not destroy them), so the
// pixels are baked without them and each mask then applies once to the baked raster.
let px = bake(l, canvas, fmt, true, false);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.content = LayerContent::Raster(px);
l.effects = Default::default();
l.mask = None;
l.vector_mask = None;
l.fill_opacity = 1.0;
l.fill_cache = None;
l.psd_blocks.retain(|(k, _)| !matches!(k, b"TySh" | b"SoLd" | b"PlLd" | b"SoLE" | b"vmsk" | b"vsms" | b"vogk" | b"vscg" | b"vstk"));
@@ -1096,7 +1105,7 @@ fn flatten_all_masks(s: &mut Session) -> Result<Value> {
skipped += 1;
continue;
}
let px = bake(l, canvas, fmt, false);
let px = bake(l, canvas, fmt, false, true);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.content = LayerContent::Raster(px);
l.mask = None;
+112
View File
@@ -554,6 +554,118 @@ fn flatten_all_layer_effects_and_masks() {
}
}
/// Flatten All Layer Effects must bake the effect visuals without destroying the layer's masks:
/// an enabled raster mask and a vector mask on one layer, a disabled raster mask on another
/// (#3804). The command is a visual no-op; the masks (pixels and state) survive, including a
/// `.pcraft` round trip.
#[test]
fn flatten_all_layer_effects_keeps_masks() {
for depth in [8, 16, 32] {
let mut s = session(40, 40, depth);
// Layer 1: a red square with a green colour overlay, an enabled raster mask revealing
// x < 20 and a vector mask revealing x 15..30.
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let id1 = s.active().unwrap().active_layer.unwrap();
s.edit("fx", |doc, _| {
let l = doc.layer_mut(id1).unwrap();
l.effects.enabled = true;
l.effects.items.push(photocraft_doc::Effect::ColorOverlay {
common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
color: photocraft_color::Color::rgb(0.0, 1.0, 0.0),
});
l.vector_mask = Some(photocraft_doc::VectorMask::new(photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[
(15.0, 15.0),
(30.0, 15.0),
(30.0, 30.0),
(15.0, 30.0),
])])));
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 40})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
// Layer 2: a red square at the bottom-right with the same overlay and a *disabled* mask.
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 30, "y": 30, "width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let id2 = s.active().unwrap().active_layer.unwrap();
s.edit("fx", |doc, _| {
let l = doc.layer_mut(id2).unwrap();
l.effects.enabled = true;
l.effects.items.push(photocraft_doc::Effect::ColorOverlay {
common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
color: photocraft_color::Color::rgb(0.0, 1.0, 0.0),
});
Ok(())
})
.unwrap();
s.execute("select.rect", json!({"x": 34, "y": 34, "width": 2, "height": 2})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.edit("disable", |doc, _| {
if let Some(m) = doc.layer_mut(id2).and_then(|l| l.mask.as_mut()) {
m.enabled = false;
}
Ok(())
})
.unwrap();
let m1 = doc(&s).layer(id1).unwrap().mask.clone();
let v1 = doc(&s).layer(id1).unwrap().vector_mask.clone();
let m2 = doc(&s).layer(id2).unwrap().mask.clone();
assert_eq!(m1.as_ref().map(|m| m.enabled), Some(true), "layer 1 raster mask is enabled");
assert!(v1.as_ref().is_some_and(|v| v.enabled), "layer 1 vector mask is enabled");
assert_eq!(m2.as_ref().map(|m| m.enabled), Some(false), "layer 2 raster mask is disabled");
let before = [
composite(&s, 17, 15), // layer 1: inside both masks
composite(&s, 22, 15), // layer 1: raster mask hides
composite(&s, 12, 15), // layer 1: vector mask hides
composite(&s, 34, 34), // layer 2: the disabled mask does not hide
];
assert!(before[0][1] > 0.99 && before[0][0] < 0.01, "overlay renders green: {:?}", before[0]);
// The white background shows where a mask hides the layer.
assert!(before[1][0] > 0.99 && before[1][1] > 0.99, "the raster mask hides: {:?}", before[1]);
assert!(before[2][0] > 0.99 && before[2][1] > 0.99, "the vector mask hides: {:?}", before[2]);
assert!(before[3][1] > 0.99, "the disabled mask does not hide");
let r = s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap();
assert_eq!(r["flattened"], 2);
{
let d = doc(&s);
assert_eq!(d.layer(id1).unwrap().mask, m1, "layer 1 raster mask survives");
assert_eq!(d.layer(id1).unwrap().vector_mask, v1, "layer 1 vector mask survives");
assert_eq!(d.layer(id2).unwrap().mask, m2, "the disabled mask survives");
}
let after = [composite(&s, 17, 15), composite(&s, 22, 15), composite(&s, 12, 15), composite(&s, 34, 34)];
for (a, b) in after.iter().zip(&before) {
for k in 0..4 {
assert!((a[k] - b[k]).abs() < 0.01, "flattening is a visual no-op: {after:?} vs {before:?}");
}
}
// One history step: undo restores the effects, redo re-flattens, and the masks stay.
assert!(s.undo());
assert!(!doc(&s).layer(id1).unwrap().effects.items.is_empty());
assert_eq!(doc(&s).layer(id1).unwrap().mask, m1);
assert!(s.redo());
assert_eq!(doc(&s).layer(id1).unwrap().mask, m1);
assert_eq!(doc(&s).layer(id1).unwrap().vector_mask, v1);
assert_eq!(doc(&s).layer(id2).unwrap().mask, m2);
// The masks survive a `.pcraft` save and reopen.
let dir = tmp("flatten-effects-masks");
let path = join(&dir, "masks.pcraft");
save_doc(doc(&s), &path, None).unwrap();
let bytes = std::fs::read(&path).unwrap();
let mut s2 = Session::new();
open_bytes_as(&mut s2, "masks.pcraft", &bytes, None, None).unwrap();
let layers: Vec<_> = doc(&s2).walk().into_iter().map(|(_, _, l)| l).collect();
assert!(layers.iter().any(|l| l.mask == m1 && l.vector_mask == v1), "the raster and vector masks survive .pcraft");
assert!(layers.iter().any(|l| l.mask == m2), "the disabled mask survives .pcraft");
}
}
#[test]
fn layers_to_files_writes_each_visible_layer() {
let dir = tmp("layers");
+1 -1
View File
@@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
"pattern" => {
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?;
// Photoshop's Fill uses the canvas origin.
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
out["pattern"] = json!(pat.id);
+50 -2
View File
@@ -1,5 +1,6 @@
//! Photoshop's fill keys (#249): ⌥⌫ fills the selection (or the whole layer) with the foreground
//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. Photoshop
//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. ⌘⌥⌫ fills
//! from history: the History Brush's source state (the oldest state still held). Photoshop
//! has no menu item for them; here they are ordinary commands with default shortcuts, so they
//! appear in Edit › Keyboard Shortcuts (Tools) and can be rebound, recorded and driven by agents.
//!
@@ -12,7 +13,7 @@ use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"];
pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"];
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##;
@@ -39,6 +40,22 @@ fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool)
(edit_fill.run)(s, &Value::Object(q))
}
/// ⌘⌥⌫: `edit.fill` from history, the History Brush's source unless `state` names another.
fn fill_history(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.fillHistory";
let mut q: Map<String, Value> = match p {
Value::Object(m) => m.clone(),
Value::Null => Map::new(),
_ => return Err(EngineError::BadParams { cmd: CMD.into(), msg: "expected an object".into() }),
};
for k in ["color", "contents", "pattern"] {
q.remove(k);
}
q.insert("contents".into(), json!("history"));
let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| EngineError::Other("Fill is unavailable".into()))?;
(edit_fill.run)(s, &Value::Object(q))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
@@ -81,6 +98,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: |s, p| fill(s, p, "edit.fillBackgroundPreserve", true, true),
journal: true,
},
CommandSpec {
id: "edit.fillHistory",
label: "Fill from History",
menu: &[],
shortcut: Some("Cmd+Alt+Backspace"),
params: r##"{"state":n? (default: the History Brush's source, the oldest state still held),"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?,"preserveTransparency":bool?}"##,
enabled,
run: fill_history,
journal: true,
},
]
}
@@ -133,6 +160,27 @@ mod tests {
}
}
#[test]
fn fill_from_history_restores_the_history_brush_source_in_the_selection() {
let mut s = session();
assert_eq!(crate::commands::find("edit.fillHistory").and_then(|c| c.shortcut), Some("Cmd+Alt+Backspace"));
s.execute("edit.fillForeground", json!({})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 6})).unwrap();
s.execute("edit.fillHistory", json!({})).unwrap();
// The oldest state (the new, transparent layer) comes back inside the selection only.
assert_eq!(px(&s, 1, 1)[3], 0.0);
assert_eq!(px(&s, 6, 1), [1.0, 0.0, 0.0, 1.0]);
// A named state, and refused ones change nothing.
s.execute("select.deselect", json!({})).unwrap();
let steps = s.active().unwrap().history.past_len();
for bad in [json!({"state": 99}), json!({"state": "x"}), json!(7)] {
assert!(s.execute("edit.fillHistory", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.active().unwrap().history.past_len(), steps);
s.execute("edit.fillHistory", json!({"state": 1})).unwrap();
assert_eq!(px(&s, 1, 1), [1.0, 0.0, 0.0, 1.0], "state 1 is after the foreground fill");
}
#[test]
fn background_fills_only_the_selection() {
let mut s = session();
+4 -1
View File
@@ -185,7 +185,10 @@ pub fn apply_filter_to_surface(
if id == crate::plugin_cmds::RUN {
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
}
let fp = params_for(id, params)?;
let mut fp = params_for(id, params)?;
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
// unknown filter; the command itself already refused an unreadable map when it was added.
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
let content = surf.content_bounds();
let area = algo::output_area(&fp, content, bounds, sel_bounds);
+28 -5
View File
@@ -153,7 +153,8 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
vertical: f(p, "vertical", 10.0).clamp(-999.0, 999.0),
stretch: s(p, "fit", "stretch") != "tile",
undefined: undefined(p, "repeat"),
map: None,
// A Displace imported from PSD carries its map; `mapPath` / `mapDocument` resolve later.
map: photocraft_io::smart_map::displace_embedded_map(p),
},
"filter.distort.shear" => FilterParams::Shear {
points: list::<[f32; 2]>(p, "points").unwrap_or_else(|| {
@@ -435,10 +436,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
return Ok(());
}
let layer = p.get("mapLayer").and_then(Value::as_u64);
let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
map_from_document(&imported, layer)
let img = if let Some(path) = map_path(p) {
map_from_path(path, layer)?
} else if let Some(d) = p.get("mapDocument") {
let docs = s.documents();
let found = match d {
@@ -454,6 +453,30 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
Ok(())
}
fn map_path(p: &Value) -> Option<&str> {
p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty())
}
/// 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}")))?;
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
Ok(map_from_document(&imported, layer))
}
/// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not
/// the map, so their re-render reads the map again here; without it the filter passed the pixels
/// through. `mapDocument` needs the session and is left unresolved.
pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> {
if let FilterParams::Displace { map, .. } = fp
&& map.is_none()
&& let Some(path) = map_path(p)
{
*map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
}
Ok(())
}
/// Resolves inputs read from the target layer itself: the layer mask as Lens Blur depth.
pub(crate) fn resolve_in_layer(fp: &mut FilterParams, layer: &Layer, bounds: Rect) {
if let FilterParams::LensBlur { depth: DepthSource::LayerMask, depth_map, .. } = fp
Loaded 100 of 359 files, more files were not shown because too many files have changed in this diff. Show more