294 Commits

Author SHA1 Message Date
Marcos Paulo 1ec29e6a6f chore: release 0.8.3 2026-10-12 21:41:53 -03:00
Marcos Paulo 59a92886b8 build: refresh Cargo.lock for the masi/craft-libs fork rev 2026-10-12 21:14:02 -03:00
Marcos Paulo ae55cf7094 build: refresh Cargo.lock for the masi/craft-libs fork rev 2026-10-12 21:09:05 -03:00
Marcos Paulo cf6473dcd9 build: pin craft-ui to the same masi/craft-libs rev as craft-print 2026-10-12 21:09:05 -03:00
Marcos Paulo 122d60fb10 fix(debrand): restore .debrand rules mangled by a sync run; point craft-print at the fork 2026-10-12 21:09:05 -03:00
Marcos Paulo 60879d8776 build: use the masi/craft-libs fork; take the Catppuccin palette from craft-ui
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:09:05 -03:00
Marcos Paulo d41f9bc63d build(windows): package standalone photocraft-cli zip and auto-detect rc.exe 2026-10-12 21:09:05 -03:00
Marcos Paulo ac9e15a079 feat(ui): Catppuccin Latte, Frappé, Macchiato and Mocha themes
Four new themes on the Studio layout (Window > Theme, Preferences, ui.set),
wired through the engine's Theme/DarkTheme/LightTheme preferences. Palette
is the MIT-licensed Catppuccin one (LICENSE-catppuccin.txt, ATTRIBUTION.md).
Menu and preference labels translated for every complete-menu language.
.debrand now exempts itself from its own check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:09:05 -03:00
Marcos Paulo eb7321b0e4 chore: debrand ArtCraft and Discord references
Remove the Discord button, Help menu links and brand assets; rewrite docs,
packaging URLs and catalogs. Add .debrand rules and the craft-skills
submodule (debrand skill) to keep upstream syncs clean. Drop duplicate
Czech catalog rows that failed bundled_catalogs_are_consistent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:09:05 -03:00
Valentin Klein 46740ad4bc Brush: CMYK foreground paints the picked inks into a CMYK document (#4344) (#4347)
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)

The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

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

* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:47:14 -04:00
Przemyslaw Majdak f48a72cbe3 Codecs: open and save DPX frames (#1049) (#4104)
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image
element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16
bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit
packing 0 or 1. 10- and 12-bit code values open as U16
round(c * 65535 / (2^b - 1)) and come back exactly when written at their
depth. No colour transform: the transfer characteristic and colorimetric
specification are reported as import notes; extra image elements are
reported; the orientation is applied on import.

Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits
(EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC;
`--dpx-bits` in the CLI), following the document depth by default. Gray
with alpha is written as luma. Creator, project, copyright, creation
time, transfer, colorimetric, frame position, frame rate and time code
travel as DPX:* text metadata and are written back.

Unsupported descriptors, signed or run-length-encoded data and other bit
depths return CodecError::Unsupported; malformed and truncated files
return errors.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:04 -04:00
Valentin Klein 3ba0e9419c Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) (#4339)
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:39:55 -04:00
ambro 7366a0b429 Italian: native-speaker review of the UI catalog (#4290)
* Italian: native-speaker review of the UI catalog

Fix mistranslations: Trap (Trapping, was overprint), HDR Toning (Mappatura tonale, was chemical toning), Display Text (Mostra testo), Vine Color, Override Action "Save" Commands. Improve phrasing: Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, Conditional Mode Change (metodo, as in Image > Mode), bit depth labels. Record the review and new glossary terms in the localization docs. Credit lucAmbr0 in people.toml.

* Tests: allow Italian's compact 8/16 bit labels, which match the source

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:38:55 -04:00
Bihacsy László 992d945da7 i18n(hu): informal address in the rows added on merge (#4334)
* i18n(hu): informal address in the rows added on merge

Seven of the 23 rows translated when #3831 was merged addressed the
user with the formal verb forms; the catalog uses the informal second
person throughout. One of them also glued a hyphen to the {key}
placeholder; it now reads "{key}+kattintás: …" like the other hints.

* i18n(hu): translate the Enable animations preference

The Interface › Enable animations preference (#3273) reached main after
#3831; preference_labels_are_translated needs a Hungarian row for it.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:32:00 -04:00
Marco Arnulfo a1fbfd9a6c Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (#4333)
* Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (fixes #4303)

* Filters on a type layer: Liquify, Filter Gallery, Camera Raw, Adaptive Wide Angle, Vanishing Point and Last Filter ask too (fixes #4303)
2026-10-12 19:30:58 -04:00
Cyrill Vitkovskiy 9f98df582a Stylus: a light Windows pen tap is pressure 0, not a full-pressure click (#4072)
winit's Windows pointer path reports pen pressure 0 as no force, so a tap
lighter than the pen's first pressure level arrived as a force-less touch.
Stylus::update ignored those, saw no pen, and the tap painted at 1 like a
mouse click.

Track touch contacts: a touch move outside a contact (hovering) or any
reported force marks a pen, and a pen contact with no force yet is pressure
0. A finger on a screen without force sensing still paints at 1. A contact
that starts in the frame another lifted now keeps its force too.

Also credit @Furash in contributors/people.toml.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:27:14 -04:00
David Mann 6c0dcf428b Color Table: keep the transparent index inside the palette (#4186) (#4187)
Replacing the colours could shrink the table while the transparent index
still pointed past its end, so the palette PNG export refused the table.
A stale index is now dropped (pixels end up as with "transparent": null),
and an explicit invalid index (out of range, negative, non-integer) is a
BadParams error before any edit instead of silently keeping the old one.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:24:17 -04:00
Arif Uğur Çelik 61c52c08db Brush Settings: toggle switch rows without losing smoothing settings (#4463)
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.

Give Smoothing a default-on enable flag so disabling it preserves the
amount and modes. Old brush JSON stays compatible; toolbar amount edits
and explicit stroke overrides enable it, and ABR imports retain disabled
smoothing values.

Cover row, checkbox, lock and keyboard gestures, old section selections,
command replay, presets, legacy JSON and 8/16/32-bit rendering.

Fixes #4283

Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:23:17 -04:00
Cyrill Vitkovskiy d303bf9b83 Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip (#4105)
* Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip

Brush tips wider than 6 screen pixels always drew the centre crosshair, so
Preferences > Cursors > Show Crosshair in Brush Tip could add it but never
remove it. Photoshop shows it only when that preference is on. The
Background Eraser keeps its sampling hotspot, and Clone Stamp / Healing
Brush still show it while Option is held.

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

* rustfmt canvas.rs

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:22:05 -04:00
Vikram Bhaskaran ae72084a9c Resolve every pattern name before a batch delete removes any of them. (#4132)
* Resolve every pattern name before a batch delete removes any of them.

A missing name used to delete the earlier presets and then return an error. The pattern library is not on the document undo stack, so that partial delete could not be undone.

Fixes #4103

* Pattern batch delete: reject non-string entries and resolve against the library only

A document-only pattern resolved through pattern_cmds::resolve, so the batch
deleted earlier entries before pattern.delete refused it. Non-string array
members were silently dropped (ported from #4127).

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:19:23 -04:00
Alon 9d37e1518b fix(move): no trail when dragging a shape past the canvas edge (#3946) (#4069)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:17:36 -04:00
Nigel Ihza Firdausy 5c4ad593de fix(ui): reject rotation angles outside f32 range (#4044) 2026-10-12 19:17:31 -04:00
Nigel Ihza Firdausy fd4ba52f79 fix(ui): cancel unfinished polygon on lasso tool switch (#4021) 2026-10-12 19:17:25 -04:00
Vikram Bhaskaran 67c90e307f Quantize the colour field when Only Web Colors is on. (#4138)
* Quantize the colour field when Only Web Colors is on.

The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible.

Fixes #4107

* Only Web Colors: draw the field and slider in flat web-safe bands

The coarse interpolated mesh blended between snapped corners, so it showed a
smooth low-resolution gradient rather than bands. Draw flat cells sampled at
their centres instead, and test that every cell is a single web-safe colour.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:12:59 -04:00
Fabio Lissi 90c42f5555 Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470)
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
  on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
  active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
  imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
2026-10-12 19:12:35 -04:00
jonathanwxh-cell 3f0a633056 Make channel filter previews match the committed edit (#3979)
* Fix selected-channel filter previews and reduced Quick Mask proxies

Co-authored-by: Codex <noreply@openai.com>

* Keep the new dialog preview aligned with the selected channel

* Bound full-resolution dialog preview textures to the device limit

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:05:49 -04:00
Tommy Leonhardsen e2a35b04fd Windows: keep full screen and maximized apart; never restore full screen (#4396)
* Windows: keep full screen and maximized apart; never restore full screen

winit on Windows keeps the maximized style on a window that goes full
screen from maximized. The shell then draws the taskbar black instead of
hiding it, and on the way back the window stays maximized at the
monitor's full size, over the taskbar (#3550, #4018, #4230). Un-maximize
before entering full screen and maximize again after leaving it.

eframe also restores full screen from ui.ron, as full screen and
maximized at once; drop that flag in the window builder hook, so
PhotoCraft starts in Standard Screen Mode as its own state says.

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

* contributors: add aweussom

* view_cmds: mark the winit#4756 workaround and how to retire it

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

* rustfmt view_cmds.rs

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:05:37 -04:00
Fabio Lissi 13f5c104f6 Map the remaining frequent Photoshop action events (#219) (#4465)
- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
  centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
  layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
  transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
  decoded and written back; kept verbatim without a destination), newPlacedLayer /
  placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
  delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
  select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
2026-10-12 19:02:15 -04:00
FredApps 7015f2916d View: Esc leaves full screen modes (part of #4116) (#4145)
Esc returned to Standard only from Full Screen Mode, and was consumed in
the UI pass before tools, dialogs and popups could see it. Handle it in
the shortcut chain instead, after everything that owns Esc, for both
full screen modes, via view.screenMode.standard.
2026-10-12 19:01:24 -04:00
Erica Franco bbfaefd5f0 File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955)
The preference was stored but read nowhere, so Edit › Preferences hid it.
Never now writes Photoshop's white placeholder instead of the merged image
and records has_real_merged_data = false in resource 1057, without
rendering the composite: smaller files and faster saves. Always and Ask
When Saving keep the real merged image, as Image Previews already does
with Ask. A file without layer records always keeps its real merged image,
and layered TIFF still renders the composite it is made of.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:00:03 -04:00
Vikram Bhaskaran 4ff2dc33ca Fix multi-layer Delete and Move drags on a locked Background (#4109)
* Fix multi-layer Delete and Move drags on a locked Background.

Delete was removing only the active layer's targeted mask when several layers were selected. A Move drag that starts on the locked Background now draws the layer marquee instead of showing the lock dialog.

* Drop the multi-layer Delete hunk: #4078 fixes #4025

This PR keeps only the Move-on-locked-Background marquee fix (#4058).

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

* Move: a click on a locked layer still selects it before the marquee

Keeps hidden_layer_tests::auto_select_skips_hidden_layers green: Auto-Select
picks the visible Background under the pointer, and a drag's marquee replaces
that selection on release.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:56:58 -04:00
Marco Arnulfo cd36d1ea0d Delete with several layers selected deletes them, not the active layer's targeted mask (#4025) (#4078) 2026-10-12 18:52:43 -04:00
Fabio Lissi 9862dfbb09 Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456)
* Actions panel: set folders, dialog toggle column, Insert Stop (#219)

- Engine actions_edit_cmds: actions.insert (any command at a position; Insert Stop… uses it
  with actions.stopMessage), actions.dialog (the per-step dialog flag), actions.assignSet.
  Flag sets follow inserted steps; nothing edits during playback.
- Panel: loose actions first, then each set as a collapsible folder row with its count
  (ui.actions.collapsedSets); dropping an action among another set's rows moves it there;
  Save Set… on a folder's context menu; a dialog toggle column next to the check marks;
  Insert Stop… in the panel menu with a small message/Allow Continue dialog.
- Shell playback pauses on a dialog-toggled filter or adjustment step: the command's dialog
  opens seeded with the recorded values; OK runs the dialog's values and resumes from the next
  step, Cancel stops. Agents play headlessly. The play result reports paused: {step, id, dialog}.
- Docs: control protocol, scorecard AUTO-219-2 done, gaps G11.

* Translate the Actions panel's set, dialog-toggle and Insert Stop strings

* Resume a paused action only after the dialog's background job lands

A filter confirmed from a dialog runs as a background job in the desktop app; the resumed
steps met a locked document and failed. Wait for the job first (test covers background jobs).
2026-10-12 18:51:53 -04:00
Marco Arnulfo 40427f25dc Properties: a new adjustment or fill layer brings Properties to the front (#4028) (#4079) 2026-10-12 18:47:59 -04:00
zenjabba 95dbb01ad9 fix(gpu): preflight compositor uniform buffer limits (#3931)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 18:44:09 -04:00
mroxso 23abc4302b fix(ui): default the Move tool Auto-Select target to Group (#4056) (#4093)
Photoshop ships with Auto-Select on and its target set to Group;
PhotoCraft defaulted to Layer. ToolOptions::default and the serde
default now return "group"; saved preferences keep their stored value.

Co-authored-by: highperfocused <highperfocused@pm.me>
2026-10-12 18:43:21 -04:00
Gotix-eg 049a92c288 feat(engine): recolor vector shape layer fill with Alt+Backspace and Cmd+Backspace (#3925)
* feat(shape): recolor vector shape fill via Alt/Cmd+Backspace fill keys

Photoshop fill keys (Alt+Backspace for foreground and Cmd+Backspace for background, as well as their Shift variants) now recolor the active vector shape layer's solid fill when targeting a shape layer, matching Photoshop 1:1 instead of erroring out with 'active layer is a Shape layer, not a pixel layer'. Channel and mask targets continue to be routed to edit.fill. Includes undo support, locked layer checks, and regression tests.

* Fill keys: keep Fill from History's own enablement after merging main; rustfmt

edit.fillHistory joined the shared fill-key IDS on main and shares their
enabled check, which now also accepts shape layers. Give Fill from History
edit.fill's check so it isn't enabled where it can only fail, and limit the
locked-shape test to the four colour keys.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:40:05 -04:00
Fabio Lissi 3204231d7d Load and save Photoshop Actions (.atn) (#4356, #219) (#4434)
* Load and save Photoshop Actions (.atn) (#219)

- photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip,
  truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and
  `Idnt` reference items carry a class before the number (real action files failed to parse).
- photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters,
  adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/
  hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop).
  Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at
  playback by name); tool selections load switched off. Commands `actions.load`,
  `actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs`
  and `set`. `layer` params accept a layer name, so loaded steps replay in any document.
- UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled.
- CLI: `batch --actions set.atn`. Automation policy covers the new path params.
- Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2
  partial, development guide.

* Translate the Actions panel's Load/Save strings in every catalog

The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label)
were missing from all 19 catalogs; the languages that were complete are back at 100%.

* Show the Actions entries in the default dock's panel menu

The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so
Load Actions… and Save Actions… were missing there.
2026-10-12 18:39:57 -04:00
zenjabba dd992c7e57 fix(color): validate RGB working profiles atomically (#3924)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 18:34:59 -04:00
jonathanwxh-cell 7f8b5816ca Use whole group bounds for transforms with active selections (#3970)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 18:30:20 -04:00
xsvm 63fd5ec7b6 Selection: lasso polygons scan only the edges that cross each row (#3959)
* Selection: lasso polygons scan only the edges that cross each row

The polygon fill behind Lasso, Polygonal Lasso and Magnetic Lasso tested
every edge on every sub-row of the whole document, O(height x 4 x points),
on one thread. It now keeps an active edge list over the edges sorted by
their top and fills bands of 64 rows in parallel. Crossings use the same
formula and spans accumulate in the same sorted order, so the mask is bit
identical; a test compares it with the previous fill.

* Selection: the lasso fill bands go through par_rows; edges are a named struct
2026-10-12 18:26:39 -04:00
Gert Pellin - GPE d32d3f41bb Save As: a saved .pcraft manifest reports the file it was saved under (#3312) (#3571)
photocraft_io::named_for_file(doc, name) serialises the document under the
file it is being written to, so a .pcraft manifest carries the identity the
save is about to adopt rather than the previous one — the same rule every
other format follows on import, where document.name comes from the file it
was read from. A bare extension names no file and changes nothing.

Applied once, in the .pcraft branch of export_stages, which export and
export_with both funnel through: the desktop Save (services.rs:242),
Export (services.rs:384), the web build (web.rs:355) and the engine file
command (file_cmds.rs:295) are covered by one site, and the previews
rendered before the bytes describe the same document as the manifest.

Scope per review: the automation funnels (files::save / save_bytes) already
landed in #3319 with their own named_for_output, so this carries no
crates/automation change; its scenarios are covered by
crates/automation/tests/native_save_name.rs on main.
2026-10-12 18:19:22 -04:00
jonathanwxh-cell 50b97605fb Convert grayscale profiles before untagged exports (#3978)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 18:13:21 -04:00
John Alister ddf6696dce Pencil: no 8-bit dither on aliased strokes, one flat colour at partial opacity (#3990)
The 8-bit stroke dither (render.rs `dither8`) ran on every stroke, including
aliased ones. An aliased stroke paints each pixel by the same amount, so it
has no gradient to band; dithering only scattered it over neighbouring
levels. A 1 px Pencil line of 219/41/61 at 30% opacity over white came out
as four colours (244/191/197, 245/191/197, 244/190/196, ...), and a
foreground between 8-bit levels did the same at 100%. Photoshop's Pencil
paints one value, and pixel art relies on it: a tolerance-0 Magic Wand or
Paint Bucket and Indexed Color palettes trip on the grain.

Composite now skips the dither when the brush is aliased (Pencil, Eraser
Pencil and Block modes). Soft brushes keep it.

Test: an_aliased_stroke_at_partial_opacity_is_one_flat_colour (fails
before the fix at pixel 5). photocraft-paint 96/96, photocraft-engine
1529/1529, ui-egui pencil 13/13, clippy -D warnings and fmt clean.

Co-authored-by: cloudalister <ngnlif3@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:10:29 -04:00
buivanvu94 e753a71851 feat(automation): allow Crop and Straighten Photos through automation (#1052) (#3917) 2026-10-12 18:03:32 -04:00
Valentin Klein 5dac5c6d61 Tests: allow-list language-invariant bit-depth labels, add missing tr/hu 'Enable animations' (#4359) (#4362)
* Tests: language-invariant bit-depth labels are allow-listed; add missing tr/hu 'Enable animations' (#4359)

Turkish writes '8 bit'/'16 bit' exactly like the English source, so the
translated-differs assertion needs an explicit allow-list (the entry must
still exist). hu and tr had no 'Enable animations' preference label.

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

* Merge main; drop the Enable animations rows main already has

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 17:33:30 -04:00
skylar taylor-barrick b3222ee8e7 Merge pull request #3872 from skylartaylor/codex/photocraft-craft-print
Use craft-print for system printing
2026-10-12 15:00:09 -06:00
skylar taylor-barrick 91b08488c8 Validate paired purge availability in MCP resource tests 2026-10-12 20:55:44 +02:00
skylar taylor-barrick 7b0317ff41 Merge PhotoCraft 0.8.2 and stabilize background-job tests 2026-10-12 20:21:10 +02:00
skylar taylor-barrick a301e31864 Merge current main fixes and validate craft-print rollout 2026-10-12 19:39:05 +02:00
Claude d43db3e153 Release 0.8.2: web build back under the wasm size gate
0.8.1's Web job failed: photocraft-web's wasm was 25,587,059 bytes, over
packaging/web/package.sh's 24 MiB gate. #4253 merged with #4147 brings
it to 24,161,781.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:57:00 -04:00
Claude 7f9c4394c1 Merge #4253: wasm back under the 24 MiB gate
Combined with #4147's opt-level "z" list. Conflict in Cargo.toml's
wasm-release profile: kept #4147's photocraft-psd = "z" and dropped
#4253's "s" entry for it (one table per package). Measured with trunk
--release: main 184c226a is 25,235,857 bytes (over the 25,165,824 gate);
with this merge, 24,161,781.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:56:54 -04:00
Brandon Thomas 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:48:28 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 11:11:02 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 11:08:34 -04:00
Bihacsy László 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:07:17 -04:00
Ufuk Efe 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:01:46 -04:00
Sean Dellis 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <8284204+switch87@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <235524086+RaccoonPie-314@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: Gert Pellin - GPE <switch87@users.noreply.github.com>
Co-authored-by: switch87 <8284204+switch87@users.noreply.github.com>
Co-authored-by: RaccoonPie-314 <alsorandomkay@gmail.com>
Co-authored-by: Raccoon <235524086+RaccoonPie-314@users.noreply.github.com>
Co-authored-by: Sir Starch <starch@velx.cc>
Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>
Co-authored-by: myangaa1 <58806636+myangaa1@users.noreply.github.com>
Co-authored-by: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com>
2026-10-12 10:52:19 -04:00
zenjabba 9163afbdcf Verify PngSuite archives and cached corpus files (#3973)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 10:45:05 -04:00
Streetblock 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 Klein a8b52f1dbf Codecs: enable JPEG and CCITT fax compression for TIFF (#3712) (#3727)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:34:45 -04:00
Valentin Klein 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 <noreply@anthropic.com>
2026-10-12 10:33:21 -04:00
skylar taylor-barrick 27fecdab44 Translate print queue guidance in new Vietnamese catalog 2026-10-12 16:22:43 +02: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
skylar taylor-barrick 5fe4d09478 Merge current main while preserving craft-print transport contracts 2026-10-12 16:17:51 +02:00
Valentin Klein 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:08:20 -04:00
Valentin Klein 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:06:16 -04:00
JGF 5a0e2e2658 Web: wasm back under the 24 MiB gate with ~1 MiB headroom (25.38 -> 24.17 MB)
The "web build and wasm size gate" job failed on main and every PR: the
release `_bg.wasm` measured 25,379,677 bytes at 8d49ecf5, over the
25,165,824-byte limit in packaging/web/package.sh.

- egui's default fonts (Ubuntu Light, Hack, Noto Emoji, emoji-icon-font)
  were embedded uncompressed (1.4 MB) by egui's `default_fonts` feature.
  The app now builds egui without it; ui-egui's build.rs deflates the
  same files from the `epaint_default_fonts` crate (a build-dependency,
  so no font files are committed) and `egui_fonts.rs` rebuilds egui's
  exact default stack from them on first use. Test builds keep the
  feature as the oracle: the rebuilt definitions equal egui's byte for
  byte.
- Translation catalogs: every catalog repeated the same English sources.
  build.rs now stores the `context<TAB>source` keys once and each catalog
  as references plus translations, losslessly (`text()` still returns
  each .tsv byte for byte; malformed lines are kept as they are).
- wasm-release profile: opt-level "z" for PhotoCraft's own crates.
  Dependencies stay at "s" (at "z" the wasm grew 0.4 MB, measured), the
  file-decoding crates (codecs, psd, raw, heif, vector) stay at "s" and
  the pixel crates at 3, so decoding and filters keep their speed.

Measured with `trunk build --release` (trunk 0.21.14, wasm-opt v123 -Oz):
  upstream main 8d49ecf5      25,379,677 bytes
  this change                 24,167,186 bytes (-1,212,491)
  gate                        25,165,824 bytes (998,638 headroom)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:59:25 -04:00
HEOJUNFO 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) <noreply@anthropic.com>
2026-10-12 09:57:11 -04:00
jonathanwxh-cell a58b6f3d9e Mix filtered selection edges with premultiplied alpha (#3895)
* Mix filtered selection edges with premultiplied alpha

Co-authored-by: Codex <noreply@openai.com>

* Update stylize kernel comparison for alpha-aware selection mixing

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 09:52:59 -04:00
jonathanwxh-cell 8072636e0f Count video footage in history memory budgets (#3894)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
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 <153173854+FredApps@users.noreply.github.com>
2026-10-12 09:43:34 -04:00
FredApps 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:42:58 -04:00
jonathanwxh-cell 4010630c98 Preserve surviving channel targets and visibility on deletion (#3887)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 09:32:37 -04:00
Derek Gliwa 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 <2328690+dgliwa@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:28:37 -04:00
FredApps 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:28:31 -04:00
FinnNk 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:43 -04:00
Sir Starch aa4d4859b4 fix(io): preserve indexed BMP color tables (#3802) (#3869)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:13:38 -04:00
MrCloudy2 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:05:22 -04:00
Gotix-eg 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:03:40 -04:00
MrCloudy2 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:52:57 -04:00
Unemars 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

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

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

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:52:45 -04:00
Reynold Hu 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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
MrCloudy2 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 <noreply@anthropic.com>
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
MrCloudy2 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 <noreply@anthropic.com>
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
Fabio Lussi 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 <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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
Fabio Lussi 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 <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:33:50 -04:00
David Mann 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) <noreply@anthropic.com>
2026-10-12 08:33:28 -04:00
jonathanwxh-cell bacc886a6d Preserve signed HDR colors during surface transforms (#3857)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 08:32:45 -04:00
jonathanwxh-cell 6037ade5ed Fix straight-path anchor insertion at the hit position (#3856)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
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
MrCloudy2 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:49:16 -04:00
MrCloudy2 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 <noreply@anthropic.com>
2026-10-12 07:49:05 -04:00
Unemars 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) <noreply@anthropic.com>
2026-10-12 07:48:55 -04:00
Dennis 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 <noreply@anthropic.com>
2026-10-12 07:48:45 -04:00
David Mann 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) <noreply@anthropic.com>
2026-10-12 07:48:35 -04:00
David Mann 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) <noreply@anthropic.com>
2026-10-12 07:48:24 -04:00
MrCloudy2 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 <noreply@anthropic.com>
2026-10-12 07:48:14 -04:00
buivanvu94 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:48:03 -04:00
JGF 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:47:33 -04:00
FinnNk 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:32:23 -04:00
Fabio Lussi 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 <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:57 -04:00
Valentin Klein 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 <noreply@anthropic.com>
2026-10-12 07:13:45 -04:00
Fabio Lussi 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 <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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
Nigel Ihza Firdausy e54adc250c feat(codecs): decode legacy PCX images and palettes (#3692) (#3701)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 Klein 476f983876 Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:13 -04:00
Valentin Klein 7893a6e04d PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:00 -04:00
Valentin Klein 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 <noreply@anthropic.com>
2026-10-12 07:11:41 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 07:04:33 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 07:02:10 -04:00
Nigel Ihza Firdausy 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:01:20 -04:00
Scott Macdonald 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:49:25 -04:00
kayrockscreenprinting 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Format the type warp texel read

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

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:37:15 -04:00
Arthur Wagner 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:30:14 -04:00
Scott Macdonald 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 <noreply@anthropic.com>
2026-10-12 06:28:55 -04:00
Scott Macdonald 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 <noreply@anthropic.com>
2026-10-12 06:28:38 -04:00
Scott Macdonald 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 <noreply@anthropic.com>
2026-10-12 06:28:33 -04:00
Valentin Klein 64a02107ba PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:18 -04:00
Valentin Klein 16341720c0 PSD: bound the zero padding of undersized pattern channels (#3652) (#3686)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:14 -04:00
Valentin Klein 7c77143b97 PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:09 -04:00
Valentin Klein 04396dd85f PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:27:57 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 06:20:44 -04:00
zenjabba ce16e100b0 Expose WebP and optional Batch export quality (#3956)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
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 Thomas 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:11:33 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 06:06:46 -04:00
Tarek Mohamed 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 Ghenof 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 <noreply@anthropic.com>
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 Thomas 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 <noreply@anthropic.com>
2026-10-12 05:56:09 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 05:50:36 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 05:45:50 -04:00
Brandon Thomas 3d0c819d46 Release prep: 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:37 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
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
Scott C Hughes 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:32:03 -04:00
n-delic 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 05:22:58 -04:00
jonathanwxh-cell bade115202 Fail CLI run when recorded action playback fails (#3861)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
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
zenjabba ad5fb53f68 Web: avoid shifting canvas during preset saves (#3805)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 05:14:07 -04:00
JGF 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 <noreply@anthropic.com>
2026-10-12 05:13:48 -04:00
Valentin Klein 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 <noreply@anthropic.com>
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
David Mann 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) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 04:52:23 -04:00
MrCloudy2 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 <noreply@anthropic.com>
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 <153173854+FredApps@users.noreply.github.com>
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
Unemars 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) <noreply@anthropic.com>
2026-10-12 04:33:36 -04:00
Fabio Lussi 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 <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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
MrCloudy2 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 <noreply@anthropic.com>
2026-10-12 04:32:33 -04:00
MrCloudy2 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 <noreply@anthropic.com>
2026-10-12 04:22:10 -04:00
kayrockscreenprinting 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 04:18:33 -04:00
agent-346022 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 <unconfigured@invalid.local>
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 Mann 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) <noreply@anthropic.com>
2026-10-12 04:10:50 -04:00
agent-346022 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 <unconfigured@invalid.local>
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 Klein 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 <noreply@anthropic.com>
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 Lins 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 <echelon@gmail.com>
2026-10-12 03:28:09 -04:00
Trần Nhật Duy 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 <63176793+tonguyenducmanh@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:05:18 -04:00
agent-346022 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 <unconfigured@invalid.local>
2026-10-12 02:49:08 -04:00
Valentin Klein 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 <noreply@anthropic.com>
2026-10-12 02:42:40 -04:00
Dan Hiorns 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 <noreply@anthropic.com>
2026-10-12 02:26:36 -04:00
Skyfuller 6a852f8852 fix(ui): correct the clipping-mask indicator orientation (#3037)
* fix(ui): correct the clipping-mask indicator orientation

* Drop the before/after screenshots from the repository

Docs screenshots stay out of the tree; the PR description keeps them.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:10:18 -04:00
Dan Hiorns cb1c06fc57 GPU memory budget: read installed RAM on Windows (#3189)
physical_memory() had no Windows implementation, so the wgpu
compositor's budget capped layer textures at a quarter of an assumed
16 GB on every Windows machine: too little on a large one (4 GB of a
62 GB PC's 8 GB Memory Usage allowance) and too much on an 8 GB laptop
whose integrated GPU shares that RAM. Read it with GlobalMemoryStatusEx
through winsafe, the safe binding the desktop app already uses (same
pinned version, kernel feature only).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:43:06 -04:00
skylar taylor-barrick 83fcbe7d9b Merge current main while preserving hardened print contracts through craft-print 2026-10-12 07:36:25 +02:00
Sir Starch f14817a5d2 Animated GIF: every frame loads into video layers (#572) (#2999)
* feat(codecs): decode every frame of an animated GIF (#572)

decode_gif_frames returns each frame composited onto the logical screen
(disposal methods applied) with its delay in milliseconds. decode still
keeps the first frame only. A damaged later frame keeps the frames before
it and marks the first with DecodeWarning::Truncated; dimensions, the
total decoded bytes and a frame cap are checked against the limits.

* feat(io): import_gif_frames hands each GIF frame over as a document (#572)

A frame at a time becomes a single-layer document, so a caller can
flatten and drop it instead of holding every frame twice.

* feat(engine): animated GIFs load every frame into video layers (#572)

New Video Layer from File, Replace Footage, Reload Frame and Video Frames
to Layers take all frames of a GIF instead of the first. The timeline rate
comes from the GIF's delays (the most common one, delays under 20 ms shown
at 100 ms as browsers do); an explicit fps still wins.

* docs: animated GIF frames in video layers (#572)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:26:37 -04:00
skylar taylor-barrick 61b6276c78 Security: harden document exports and automation inputs (#2269)
* chore: clear strict Clippy simplifications

* security: harden web exports and generated image assets

* security: reject redirected directory bundle writes

* security: quote paths in droplet shell launchers

* security: use private ephemeral print spool files

* security: bound automation file reads and MCP input

* security: bound MCP transport requests before dispatch

* chore: keep current sample checks Clippy clean

* security: preserve safe export roots and print semantics

* security: clear non-sending print state before replay

* security: forget print send overrides between jobs

* chore: keep macOS UI tests and Clippy clean

* Test web export paths portably on Windows
2026-10-11 23:18:52 -06:00
Sir Starch b4a949225d Support automatic monitor ICC profiles on Linux X11 (#2963)
* Linux X11: detect and apply monitor ICC profiles

* Test Linux monitor ICC profile handling

* Gate display detection test to supported platforms
2026-10-12 01:17:59 -04:00
Sir Starch e8c7656150 Use each XRandR output's ICC profile in the screen picker (#2962)
* Screen picker: use each XRandR output's ICC profile

* Test X11 screen picker ICC validation
2026-10-12 01:09:03 -04:00
Sir Starch 82c58fabab Use Fontconfig for Linux font discovery (#2330) (#2961)
* Fix #2330: discover installed fonts through Fontconfig

* Fix wasm cfg for Fontconfig fallback

* Add Linux font discovery regression tests

* Reject Fontconfig matches without CJK coverage
2026-10-12 01:05:56 -04:00
Valentin Klein 5e0a6d404d Tools: Shift key switches tools by default, as in Photoshop (#3525) (#3528)
* Tools: Shift key switches tools by default, as in Photoshop (#3525)

Use Shift Key for Tool Switch is on by default in Photoshop: T keeps the
current tool, Shift+T cycles. Saved settings keep their stored value.

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

* Tests: the M-key marquee cycling test sends Shift+M, the new default (#3296)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:54:41 -04:00
FinnNk 962e0a2f98 feat(ui): Photoshop's New Guide: units and colour (#3553)
* feat(engine): accept Photoshop units for guide positions

view.newGuide and view.moveGuide take the position as before (a number
of pixels) or as text with a unit, as Photoshop's New Guide dialog does:
"50%", "2.5 cm", "1in", "12pt", "3pc", "100px". Percent is of the
document's width for a vertical guide and its height for a horizontal
one; points and picas follow Units & Rulers › Point Size. Fractional
positions are kept. Text that isn't a length, NaN and positions beyond
1,000,000 px are refused without recording a history step.

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

* feat(engine): guides can have their own colour

Photoshop's New Guide sets a colour per guide; PhotoCraft guides only had
the Preferences guide colour. Guides now keep optional colours, keyed by
orientation and position so code that drops or reorders guide positions
(crop, artboards, guide layouts) can never move a colour onto another
guide: an unmatched guide just uses the preference colour.

view.newGuide takes an optional "color": a name from New Guide's list
(lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, lightGray,
black) or "#rrggbb". Without one the guide uses the preference colour, as
guides dragged from the rulers always have. Moving a guide carries its
colour; deleting and clearing drop it (Clear Guides, Clear Canvas Guides,
Clear Selected Artboard Guides and New Guide Layout's Clear Existing
Guides alike); undo restores it. Guides are drawn in their own colour
(while New Guide Layout previews, the rest show the colour it will set), and .pcraft files keep it (omitted when no guide has
one, so older readers and files are unaffected). PSD export keeps guide
positions only.

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

* feat(ui): Photoshop's New Guide dialog: units and colour

View › Guides › New Guide… was the generic schema dialog, whose position
field was whole pixels only and had no colour. It now has its own
dialog, as in Photoshop:

- Position opens in the ruler unit ("0 cm") and takes any unit
  Photoshop's field takes ("50%", "2.5 cm", "12 pt"); a bare number is
  in the ruler unit. The last guide reopens in whatever the ruler unit
  is then ("5.08 cm").
- Color lists Custom…, Light Blue, Light Red, Green, Medium Blue,
  Yellow, Magenta, Cyan (the default), Light Gray and Black. Custom…
  shows a swatch that opens the Color Picker. The guide stores the
  chosen colour itself, so a later change of the preference guide
  colour leaves it alone.
- A position the engine refuses reopens the dialog with what was typed
  and the engine's message, as Photoshop keeps New Guide open.

Light Blue, Light Red, Medium Blue and Light Gray are new to the
catalogues; they were translated for all 16 languages from the existing
colour terms in each and would benefit from native review.

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

* refactor(engine): one unit-suffix table for typed lengths

New Guide's position field (Unit::parse_length) and the Crop tool's size
fields (crop_size::unit_of) each kept the same table of unit suffixes.
Unit::from_suffix is now the one table: px, in or ", cm, mm, pt, pc or
pica, % and their long forms, in any case. The crop fields keep their own
number parsing (they accept arithmetic) and still refuse percent; they now
also accept "pc" for picas.

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

* feat(ui): New Guide and New Guide Layout share one colour list

New Guide Layout (#2930) offered five colours of its own; New Guide offers
Photoshop's nine. Both dialogs now list the same nine (GUIDE_COLORS), in
Photoshop's order and with the same translated names. Cyan (#4affff, the
default guide colour), Magenta (#ff00ff) and Yellow (#ffcc00) take New
Guide Layout's values; Light Blue, Light Red, Green, Medium Blue, Light
Gray and Black keep New Guide's. New Guide Layout's Blue (#378ef0) and
jade Green (#00a86b) give way to Photoshop's Light Blue and Green; a colour
outside the list still shows as Custom.

GUIDE_COLORS drops its unused label column (the dialogs translate the
names), and New Guide Layout reads "#rrggbb" with prefs::parse_hex instead
of its own copy (rulers::parse_guide_color).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:46:56 -04:00
F4LCON 973e7776ea History panel: snapshots (camera button, snapshot rows) (#3545)
Part of #2966. The History panel's camera button takes a named snapshot
of the document ("Snapshot N", not a history state). Snapshots are
listed under the opening state with a thumbnail and scroll with the
states; clicking one returns the document to it as one new, undoable
state named after it. A double-click or right-click › Rename… renames
it in place, and right-click › Delete removes it. Snapshots live for the
session only, as in Photoshop.

For agents: history.newSnapshot {name?, from?: fullDocument |
mergedLayers | currentLayer}, history.selectSnapshot, renameSnapshot and
deleteSnapshot, addressed by ids that are never reused, and doc.inspect
reports snapshots.
2026-10-12 00:35:56 -04:00
skylar taylor-barrick 2bf49f10a7 Security: verify AppImage build inputs (#2270)
* security: pin and verify release build inputs

* docs: explain verified offline AppImage inputs

* security: validate executable AppImage tooling offline

* security: preserve read-only verified AppImage caches

* test: cover verified runtime downloads and cache diagnostics

* test: assert verified runtime mode and cache diagnostics

* Fix shellcheck ordering in verified download test

* Use external chmod for cache test setup
2026-10-11 22:33:26 -06:00
Valentin Klein 80d646fc17 Brush: draw the smoothing string from the brush to the pointer while drawing (#3479) (#3526)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:26:04 -04:00
Scott C Hughes ee05bc833b fix(io): a fresh Metrics type layer writes no empty Txt2 stub (#3514) (#3529)
PSD export always wrote a document-level Txt2 block, and for a fresh document its
document-resources dictionary (/0) was the empty `<< >>` Photoshop never writes. Photopea's
reader fails on it ("Cannot read properties of undefined (reading '_Resources')"), so a PSD
saved with a new type layer stopped opening there.

Write the block only when it carries something: the file already had one (round-trip its pen
positions and extras), or a layer uses the Optical auto-kern mode - the one mode EngineData's
AutoKerning cannot distinguish (#1348). A plain Metrics document now writes no block, as 0.3.0
did.

Tests: text needs_txt2_only_for_optical; io fresh_metrics_text_writes_no_txt2_block.
2026-10-12 00:18:14 -04:00
Unemars 6ecad20132 Content-Aware tools: faster, cancellable at once, same pixels (P21, P22, P49) (#3395)
* Content-Aware tools: faster, cancellable at once, same pixels

#2202 matched Photoshop's Content-Aware Fill but made the perf nightly slower (#2575): P21
Content-Aware Fill 10 MP 5x, P22 Spot Healing 2.4x, P49 cancel on 24 MP 4x and over its 200 ms
budget. This keeps every result bit for bit and takes the time back:

- The solver works over the hole's bounds grown by a patch: match field, costs, votes and the
  per-target tables are kept there (nothing outside changes or is matched), the vote computes
  each target's weight once and writes into a buffer instead of a Vec per pixel.
- Colour adaptation reuses each patch's own moments (target sums kept per EM step, source sums
  per level where they pay off), summed in the same order as before.
- Exact early outs: SSD per pixel instead of per row (callers only ask "better?"), and the onion
  peel's exhaustive search starts from where the filled neighbours took their sources and stops a
  source once it can only lose (first-of-equals kept).
- PatchMatch sweeps a band in wavefronts of blocks when there are fewer bands than threads: each
  cell sees the same neighbours and random numbers (SplitMix64 skip) as in raster order.
- Parallel pyramid, valid-source and target setup; no copy of the window when there are no
  variants (fill_owned / complete_masked_owned); the job reads the window in parallel bands and
  keeps only the selection's bounds for the apply step.
- Cancellation is checked per row everywhere, also in Auto sampling and in its max flow
  (maxflow_with / grid_cut_cancellable): a 24 MP job now stops in tens of milliseconds.
- algo::par: rayon on native, sequential on wasm.

Proof: the new `content_aware_bench` example hashes every tool's output (Fill with each sampling
and adaptation option, Edit > Fill, Delete and Fill, Spot Healing x3, Patch, Content-Aware Move,
8/16/32-bit, Gray/CMYK/Lab, and the benchmarks' 10 and 24 MP fills): 117 hashes identical to
main, also with 1, 2 and 4 threads. New tests compare the onion peel with a plain scan, full and
clipped patch distances and moments, the valid-source runs with the summed-area test, thread
counts 1/3/16, in-place and copied fills, and cancellation of Auto sampling and max flow.

perf_scenarios --cpu, Ryzen 7 5800X shared with other work (load 4-7), 3 interleaved rounds:
P21 p50 295-306 -> 167-171 ms (max 342-362 -> 180-184); P22 p50 67-68 -> 38-40 ms (max 69-71 ->
40-41); P49 max 378-389 -> 54-77 ms (p50 94-110 -> 47). retouch_bench: Content-Aware Move
-47..-64 %, Spot Healing Content-Aware -40..-61 %.

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

* Content-Aware: faster Spot Healing dabs than main with #2805, same pixels

Against main with #2805 the branch was slower for small Spot Healing dabs (size 50:
+25-30 %, single-threaded +60 %): its onion peel walked all 49 offsets of every candidate patch,
checking each against the known pixels, and had no bound at the hole's edge.

- Onion peel: the target's known pixels are gathered once (#2805), the search starts from the
  filled neighbours' shifted sources or every 16th source at the hole's edge (#2805), the limit
  a candidate must stay under is a precomputed sum (no division per pixel), and candidates are
  summed four at a time in step: their sums are independent, so the processor overlaps them.
  Each is still given up only against the best from before the four, which can only improve.
- Vote: each target's weight and match centre are taken once; a pixel's patches are visited
  over the clipped offset range, with the channel count and the colour adaptation fixed at
  compile time. Same sums, same order.
- Valid sources: sliding counts per row and stacked runs per column instead of a 7-row check
  per pixel; the k-th source is found through per-row and per-64-column counts, so no level
  but the coarsest lists them all.
- `integral` stays out of line: inlined into `synthesize` it made Create Texture slower.

Same results: `content_aware_bench -- hash` (112 cases) and `-- hash-big` (the benchmarks' 10 and
24 MP fills) equal main's, also with 1 and 3 threads; the onion peel tests (#2805's and ours)
compare with the plain scan. `content_aware_bench bench spot` runs retouch_bench's Spot Healing
dabs; with `CAF_SPOT_TYPE` (comma-separated) other types, with page faults per dab.

Ryzen 7 5800X, shared (load 5-20), interleaved with main (3af4c46a), min of rounds,
retouch_bench, all threads / one thread:
Spot Healing Content-Aware 50: 49 -> 31 / 204 -> 117 ms; 100: 62 -> 35 / 164 -> 98 ms;
200: 118 -> 67 / 250 -> 158 ms; 300: 212 -> 119 / 411 -> 285 ms. Content-Aware Move -47..-62 %
(one thread -23..-45 %). Rows that don't run this code stay within noise, except Proximity
Match 200 after the Content-Aware dabs in the same process (+8..23 %): it takes ~5800 page
faults there against ~300 on main, because main's heavier dabs left more of the heap faulted
in; alone, or after Content-Aware dabs in `bench spot`, it is equal.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 00:11:10 -04:00
F4LCON b14037083d Brush and Pencil: hold ~ to erase, and Clear mode (#3439) (#3465)
* Brush and Pencil: hold ~ to erase

Fixes #3439. Holding ~ (the backtick key) with the Brush or Pencil makes
the strokes started while it is held erase, with that tool's own brush,
as in Photoshop; releasing it paints again and the tool never changes.
The key is a Tools › Temporary binding (tools.temporary.erase), so it
can be rebound or removed in Keyboard Shortcuts.

* Brush and Pencil: Clear painting mode, used by ~

Photoshop's Clear joins the Brush's and Pencil's Mode menus, after
Dissolve: chosen there, every stroke erases with that tool's brush until
another mode is picked. Holding ~ now puts the stroke in Clear mode too,
and the Mode menu shows Clear while the key is held. History names the
tool rather than the Eraser. Agents get it as "mode": "clear" on
paint.stroke and paint.pencil.

* Keep the Smoothing doc comment on smoothing_field

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:02:42 -04:00
MrCloudy2 7e5d2ce6da Warp presets: Photoshop's Bezier control grids instead of approximations (#3444)
The 15 warp presets (Edit > Transform > Warp, smart object warps, Warp
Text) were our own closed-form approximations, up to 70 px off
Photoshop's: Arc kept its centre fixed and pulled the ends in where
Photoshop keeps the inner edge's corners and raises the middle, Flag
waved the wrong way at a third of the height, Squeeze shrank instead of
pinching.

Measured on Photoshop 27.11 (scripted renders of a grid image whose
colours encode each pixel's position, 102 cases: every preset at bend
±50 and ±100, both distortions, vertical orientation, square, wide and
tall boxes), every preset is a single bicubic Bezier patch (fits within
0.5 px). Its control grid:
- Arc: each row a circular arc of b·π around one centre, the inner edge
  keeping its chord; Arch, Bulge, Arc Lower/Upper and Shell built from
  the same arcs. Circular in pixels on any box.
- Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist: control
  points moved linearly with the bend, in box units.
- Vertical distortion scales the top and bottom rows about their
  midpoints and blends the rows between; horizontal distortion scales
  each column. Vertical orientation swaps the axes and the two
  distortions.
All 102 cases land within 1 px (median) of Photoshop. On the corpus's
Photoshop-made warped text, warp-arc-50 now spans 340x85 at (-3, 3)
against Photoshop's 350x88 at (-7, 3), where it was 278x74 at (28, 62).

Presets also convert to custom meshes exactly now (to_mesh splits the
patch instead of fitting). Tests that pinned the old shapes now assert
Photoshop's; presets_match_photoshop checks measured points.

Still open: Warp Text warps over our line box, while Photoshop uses the
type layer's stored `bounds`; with those the five corpus warp files land
within a few px (arc, flag, bulge with distortion, twist, vertical rise).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:56:17 -04:00
skylar taylor-barrick e3d118e0f3 Separate print queue status from generic cancellable progress controls 2026-10-12 05:53:56 +02:00
David Mann bea81c2f3d Save: show save progress as Saving NN% in the status bar and tab title (#3389) (#3485)
Background saves only ever reported 0 % ("Encoding", then "Writing"), because
photocraft_io::export produced all the bytes in one call: the status bar showed an
indeterminate bar and no percentage. Now:

- photocraft_io::export_with(doc, name, opts, &Interrupt) reports encoding progress
  (export keeps its signature and calls it with Interrupt::NONE, same bytes): PSD/PSB
  per layer record and per composite band, layered TIFF the same plus its encoder
  stages, .pcraft after its previews, flat formats once the composite is rendered.
  Cancelling stops the conversion early and returns IoError::Cancelled.
- photocraft_format::atomic_write_progress writes in 8 MiB chunks and reports the
  fraction written.
- The desktop save maps encoding to 0..85 % and writing to 85..99 %, 100 % once the
  file is flushed and renamed.
- The status bar reads "Saving 45%" for a save (file name on hover), and the
  document's tab title gets " - Saving 45%" in both tab strips, as in Photoshop.
  Percentages round down so 100 % only shows when done.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 23:49:53 -04:00
skylar taylor-barrick 0467f715c6 Reuse upstream counted catalogs to prevent duplicate keys in PR merge 2026-10-12 05:48:59 +02:00
skylar taylor-barrick 8c557fcb7d Place identical browser engine profile beside upstream override 2026-10-12 05:47:22 +02:00
MrCloudy2 3794ad471f Color Balance: Photoshop's per-channel levels and gamma curves (#3443)
Color Balance added a share of each slider weighted by the pixel's
lightness, then (Preserve Luminosity) rescaled to the old luma. On the
corpus's Photoshop-made file (photoshop/adjustments/rgb8/color-balance.psd,
all three tones moved, Preserve Luminosity on) that was up to 43 levels
off on 99% of the pixels.

Measured on Photoshop 27.11 (scripted: a gray ramp and 16 pure colours
through each slider at ±25, ±50 and ±100, with Preserve Luminosity on
and off, plus two and three tones and two sliders at once), every
slider is a levels-and-gamma curve on each channel:
- Preserve Luminosity off: midtones is gamma 2^-a on the slider's own
  channel; shadows gamma 2^(-a/2), after a black point at 0.39·|a| when
  lowered; highlights gamma 2^(-a/2), after a white point at 1 - 0.39·a
  when raised.
- Preserve Luminosity on: midtones gamma 2^(-a/2) on its channel and
  2^(a/2) on the other two; shadows and highlights go by the differences
  between a tone's sliders: a black point at 0.39·(max - own) and a
  white point at 1 - 0.39·(own - min) per channel.
- Tones apply highlights, shadows, then midtones.
All 88 measured cases match within 1 level, except midtones +100 below
about 28 of 255 (Photoshop flattens the steepest gamma near black, by
up to 9 levels). The corpus file is now within 4 levels (mean 1.4, was
20.9).

The GPU builds the same curves into per-channel tables and uses the
Levels/Curves kernel; the old Color Balance shader case is gone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:45:58 -04:00
skylar taylor-barrick 67ca74fe18 Align overlapping CI repairs with upstream while retaining regression coverage 2026-10-12 05:44:48 +02:00
skylar taylor-barrick d380309338 CI: restore tests, catalogs, and web size gate (#3923)
* ci: restore test and Clippy gates

* ci: keep browser wasm below size gate

* CI: restore plural catalog and MCP resource test gates

Adapted the catalog, parser, and shared-cache test fixes from PhotoCraft PR #3821 by @bommerts. The source-key test, bundled-catalog test, and MCP resource test pass locally.

* CI: expect saved native document name in CLI test

The native save path intentionally writes the output filename into the reopened bundle. Keep the pre-save Run assertion and assert r.pcraft after reopening.

* CI: compare orientation round trips without saved filenames

Native saves encode the output filename in document name. Check each expected name, then compare the remaining document fields and pixels.
2026-10-11 21:37:09 -06:00
MrCloudy2 9237a8bdde PSD gradients: read the interpolation method Photoshop writes; add Smooth (#3442)
Gradient fills and gradient effects read their interpolation method from
`gs99`, a key Photoshop doesn't write. Gradient fill layers saved by
Photoshop 27.11 store it as the enum `gradientsInterpolationMethod`
(Perc, Lnr , Smoo, Gcls), so every Perceptual, Linear or Smooth gradient
opened as Classic. It shows once the fill is re-rendered (any edit):
measured against Photoshop's own composite of the saved file, a
black→white Linear fill was up to 74 levels off and Perceptual 31; both
are now within 1. `gs99` stays as a fallback.

Smooth, Photoshop's default method since 2024 (also for Gradient Map),
had no model. Measured on Photoshop 27.11 (scripted gradients on three
colour pairs), it runs straight across each segment in Oklab with its
own easing, the same curve for every pair, and Smoothness doesn't
change it. The curve fits no simple formula, so it is a 17-point
measured table. Two-stop Smooth gradients are within 2 levels (Classic
was 31 to 83 off); with three or four stops within 11 to 25 (Classic 48
to 106), as Smooth also smooths across stops in a way not modelled yet.

Corpus: no file changes status; psd-tools effect-enums.psd (Smoo
gradient effects) goes from 7.10% to 6.13% differing pixels.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:33:46 -04:00
David Mann 39aa849b47 Raw: open monochrome LinearRaw DNGs (1 sample per pixel) (#3497) (#3503)
A one-sample LinearRaw DNG (VueScan film scans, Leica Monochrom) was
rejected as "LinearRaw data with 1 samples per pixel". The DNG decoder
now accepts it and develop renders it as neutral grey: the one sample
fills all three channels, white balance, colour matrices and camera
looks do not apply, and the output is R = G = B in 16-bit ProPhoto.
io skips the preview colour fit for such files.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 23:33:40 -04:00
Valentin Klein c20b81b251 Autosave: best-effort cleanup after commit; sweep orphaned recovery bundles (#3489) (#3502)
save_dir removed unreferenced objects with `remove_file(..)?` after the
manifest commit, so a vanished or locked file made a completed save
(autosave) report failure. The cleanup is now best-effort.

RecoveryStore::new sweeps recovery data nothing could load or clean up:
bundles without a sidecar (crash during a first autosave) and
*.pcsave.tmp files of other processes, only once untouched for 24 hours
so another instance's in-progress save is never touched.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:31:23 -04:00
Fabio Lussi 4030096482 perf: Edit > Clear works tile by tile on large selections (#3463) (#3482)
Clear (and the background-colour fill it does on a Background layer)
read the whole selection bounds into one float buffer, looked the
selection up in its tile map once per pixel and wrote every tile back,
single-threaded. On an 8000x6000 layer that took about 2 s (3 s on the
Background). It now edits the encoded tiles in parallel, drops fully
cleared tiles, shares one solid tile for fully covered opaque fills and
copies a tile only when a pixel in it changes. Results match the old
pass exactly; 20-110 ms on the same documents.

Fixes #3463

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:25 -04:00
siraxe 80b8deab0f Brush cursor: shaped tips trace their outline (split from #2516) (#3467)
Add the brush-outline half of #2516 on its own, so review and merge stay
small; the last-brush persistence half follows separately.

- paint: new `outline` module traces the tip shape as closed rings in
  radius units -- a computed ellipse for round tips (rotated/flattened),
  marching squares over the bitmap for sampled/stored tips, at the full
  tip or the 50 % alpha contour. Traced rings stay finite and bounded for
  hostile sizes; tested for the half-coverage contour, blank tips and
  hostile input.
- ui-egui: new `brush_cursor` picks the traced outline when the tip has a
  shape and falls back to the plain circle otherwise; `tool_cursor` gained
  the `Outline` shape (bitmap rasterisation by segment splatting, painter
  fallback on non-Windows).
- The Normal Brush Tip circle now goes through `half_coverage_radius`
  (#2744) so outline and circle agree on the real 50 % contour.

Review fixes from #2516:
- Merged main; docs/scorecard.md is main's version and was verified with
  `cargo xtask scorecard --check` (regeneration changes nothing).
- Dropped the scratch benchmark `crates/paint/examples/tmp_outline_bench.rs`
  and the two `#[ignore = "temp bench"]` tests in tool_cursor/tests.rs.

Co-authored-by: JJ <jj@dot.com>
2026-10-11 23:23:29 -04:00
Fabian Ghenof 17855ab3d1 Commands check an explicit "layer" instead of whichever layer is active (#3466)
A command whose params document "layer":id acts on that layer, but its
availability check looked at the active layer. With another layer selected,
`layer.smartObjects.replaceContents {"layer": N, "path": …}` failed with "the
active layer is a Group layer, not a smart object", although layer N is a
smart object; likewise Rasterize, Export Contents, Merge Down and the rest.
Scripts, recorded actions and MCP agents hit this whenever the selection
differs from the layer they name.

Two allow-lists covered part of it: #466 (smart filters, Edit Contents,
Convert to Layers) and #2457 (hide, show, rename with nothing selected). The
#466 check now applies to every command that documents "layer":id (210
today; the old list is a subset): the precondition runs with the named layer
as the active one, then the active layer is restored. A name that isn't a
layer of the active document falls back to the usual check, so bad ids still
fail as before. It moves from smart_cmds to Session, as it isn't specific to
smart objects any more.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:45 -04:00
Yousuf 'Jay' Philips afac33f8d7 Fix GIMP hole heading name and location (#3517) 2026-10-11 23:12:04 -04:00
DomiLuebben 5d8e5c138b Copy takes every selected layer, Paste recreates them (#2478) (#3394)
* fix(clipboard): Copy takes every selected layer, Paste recreates them (#2478)

With several layers selected and no pixel selection, Edit > Copy only
copied the active layer's pixels, so Paste made one layer. Photoshop
copies all selected layers and pastes them back as layers.

Copy now puts the selected layers (those no selected group already
carries, bottom-to-top) on the session clipboard as whole layers, which
Paste already knew how to recreate (paste_layers). The OS clipboard still
gets the merged pixels. A pixel selection keeps the old behaviour (the
active layer's selected pixels), and the Background in the set pastes as
an ordinary, unlocked layer, as Duplicate Layer makes it.

Tests: several layers round-trip in one undo step, a pixel selection
still copies one layer, and the Background case.

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

* test(clipboard): several copied layers paste into another document (#2478)

Copy two layers in one document, paste them into a 16-bit and a 32-bit
document: both arrive in order, converted to the target depth with their
own pixels, as one undo step, and the source document is untouched.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 23:08:12 -04:00
prekabreki 9829d7ea37 Duplicate Layer from the menu opens a dialog for the copy's name and destination document (#3348) (#3440)
Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
2026-10-11 23:02:59 -04:00
skylar taylor-barrick 82d2b7e0f1 Fill missing plural statuses in coverage-enforced catalogs 2026-10-12 04:54:47 +02:00
Nigel Ihza Firdausy f0322f74e8 fix(selection): scope Color Range to the active selection (#3313) (#3366)
* fix(selection): keep Color Range inside active selection and reject out-of-bounds samples

* fix(ui): scope Color Range dialog and canvas previews to the active selection

* test(selection): cover Color Range scoping, sampling, invert, Add and undo

* test(ui): ensure Color Range preview and pipette honor existing selection

* test(selection): avoid Debug bound when asserting prepared-query error

* test(selection): validate malformed Color Range sample orders without selection interference

* Color Range: format, and scope the OK undo test's prior selection (#3313)

The existing ok_applies_one_undo_step test picked a prior selection that
excluded the red it then expected Color Range to select; with Color Range
scoped to the active selection it now selects the red inside it.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:52:08 -04:00
kotulsky 51c671c5a0 fix(selection): load shape and path thumbnails with Ctrl-click (#3449)
Load Work Path, saved path and layer path thumbnails as selections on
Ctrl/Cmd-click. Support Shift to add, Alt to subtract and Shift+Alt to
intersect, without changing the selected path or treating the click as
Save Path. Clicking the row name keeps its existing selection behavior.

Render uncached shape transparency instead of selecting the entire canvas.
Add regression tests for thumbnail hit testing, active-layer preservation,
selection combinations, undo/redo and 8/16/32-bit uncached shapes.

Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
2026-10-11 22:51:42 -04:00
skylar taylor-barrick a538567a0d Read plural keys from form arguments and report all catalog gaps 2026-10-12 04:49:59 +02:00
skylar taylor-barrick 6651724db8 Align CLI save test with native destination filename semantics 2026-10-12 04:35:18 +02:00
Mr-Pearce 8f102b0222 feat(api): add file path to document.inspect (#3398) 2026-10-11 22:34:03 -04:00
piero-83 23417a60b6 fix(engine): create new layer masks at the document depth (#3359)
* fix(engine): use document depth for new pixel masks

* test(engine): check mask depth and selection coverage

* test(engine): preserve mask edits across native and PSD round trips
2026-10-11 22:30:26 -04:00
Thomas Han fb36a3fdc4 fix(presets): never overwrite preset or actions files that failed to load (#3346)
* fix(presets): never overwrite a brush group file that failed to load

A group file skipped at load (corrupt, newer version, missing tips) was replaced on the next sync with only the new presets. Remember skipped files and leave them unchanged with a warning.

* fix(presets): never overwrite an actions.json that failed to load

A corrupt, oversized or newer-version actions.json loaded as an empty list, and the next action change rewrote it with only the new actions. Remember the skip and leave the file unchanged with a warning.
2026-10-11 22:25:31 -04:00
Valentin Klein 60ef9d836f Format: reopening a .pcraft shares deduplicated tiles again (#3315) (#3339)
Saves store each distinct tile once (a solid fill shares one Arc<Tile>), but
the loader fetched, decompressed and charged max_total_bytes once per tile
reference and gave every coordinate its own copy. A solid canvas above
~65536² (8-bit) failed to reopen with "bundle expands beyond 17179869184
bytes", and smaller ones lost their sharing (a solid 30000² 32-bit document
went from about 1 MB to about 14 GB in memory).

The loader now keeps the decoded tiles of the current load by sample type and
content hash: each distinct tile is decoded, hash-checked and charged against
the budget once, and every further reference gets the same copy-on-write Arc.
The budget still bounds distinct decoded bytes; extra references cost a map
lookup and an Arc clone each, bounded by max_manifest_bytes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:21:45 -04:00
alexanderalber 980cf05be4 Group effects follow their children's effects, not just their pixels (#3274) (#3326)
A group's effect region and the empty-tile check were computed from
layer_bounds, which for a group is the union of its children's pixels.
A child's outside stroke reaching past that region was cut off on the
canvas (GPU) and in tiled CPU renders, and the group's drop shadow
followed the cut shape.

New photocraft_compose::effect_source_bounds: for a group, the children
grown by their own effect reach. Used by compose::effect_region,
compose::empty_in and gpu::bounds::effect_region.

generated with Claude Code
2026-10-11 22:20:23 -04:00
Valentin Klein 538c5020b2 New Document: a real Recent category of recently created documents (#3131) (#3135)
File › New › Recent was a hard-coded pair of presets plus Clipboard. Each
document created with the dialog now records its settings (size,
resolution, mode, depth, background, display units) through the new
document.recent.add command: newest first, equal entries collapsed, at most
20, persisted with the presets (a damaged entry is dropped on load, the rest
kept). The Recent tab lists Clipboard, those documents, then the defaults
they don't cover; a click restores every setting, a double-click creates,
and the grid scrolls past three rows. document.recent.list exposes the list
to agents; creation stays in file.new. Closes scorecard row UI-217-12.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:17:17 -04:00
skylar taylor-barrick 3a06871d69 Keep print queue translations alongside background job controls 2026-10-12 04:11:22 +02:00
skylar taylor-barrick 055e4fee1b Merge remote-tracking branch 'upstream/main' into codex/photocraft-craft-print-sync
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-12 04:10:55 +02:00
KnottyDyes 48c1721470 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 <noreply@anthropic.com>
2026-10-11 22:10:12 -04:00
myangaa1 27222cf4b7 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.
2026-10-11 22:05:50 -04:00
skylar taylor-barrick ce48721c6b Translate the system print queue guidance in every UI language 2026-10-12 04:04:07 +02:00
skylar taylor-barrick cf98b91f7b Remove duplicate Czech catalog rows while retaining active translations 2026-10-12 04:03:54 +02:00
Valentin Klein 0e2c34af3e 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:02:04 -04:00
skylar taylor-barrick 9db0b30910 Keep browser command orchestration within the existing size budget 2026-10-12 03:53:52 +02:00
Valentin Klein 638b5cc4e6 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 <noreply@anthropic.com>
2026-10-11 21:40:37 -04:00
Valentin Klein 7fd53f0e3f 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:40:06 -04:00
Valentin Klein 9a9d23d258 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:27:08 -04:00
Nigel Ihza Firdausy 6f6d2ea467 fix(format): save the target file name in native bundle manifests (#3312) (#3319) 2026-10-11 21:26:13 -04:00
skylar taylor-barrick f65f7cc9eb Correct stale HDR Levels test endpoints 2026-10-12 03:24:40 +02:00
Valentin Klein 6e9ba6511d 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:22:11 -04:00
Valentin Klein ee9c9ef159 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:16:28 -04:00
Valentin Klein 59820ecafb 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 <noreply@anthropic.com>
2026-10-11 21:16:07 -04:00
Sir Starch 04e7e7d7f8 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.
2026-10-11 21:14:28 -04:00
Valentin Klein aba9b2b4ef 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 <noreply@anthropic.com>

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:05:58 -04:00
RaccoonPie-314 1051265f75 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 <235524086+RaccoonPie-314@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:03:22 -04:00
Gert Pellin - GPE b0e555c60f 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 <8284204+switch87@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:01:26 -04:00
skylar taylor-barrick 4bb6ffc46f Record background print hooks as one command 2026-10-12 02:58:32 +02:00
skylar taylor-barrick cc902c681b Report refused print cancellation accurately to automation 2026-10-12 02:56:10 +02:00
skylar taylor-barrick b8ea8206cc Refresh document views after completed print hooks 2026-10-12 02:51:42 +02:00
Vikram Bhaskaran 2af2cec6ea Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267) 2026-10-11 20:50:49 -04:00
skylar taylor-barrick 223e9499c3 Keep keyboard shortcut parser available in browser builds 2026-10-12 02:46:08 +02:00
Valentin Klein 6de37ce7f0 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 20:34:58 -04:00
skylar taylor-barrick c6407cac07 Keep scorecard test reference current 2026-10-12 02:34:03 +02:00
skylar taylor-barrick bb46f846d9 Keep print completion tied to its document and hold quit 2026-10-12 02:32:26 +02:00
skylar taylor-barrick 698c0f554d Format printing completion regression 2026-10-12 02:29:59 +02:00
Valentin Klein 6d08ae6400 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 20:29:36 -04:00
skylar taylor-barrick ccee4894bd Use craft-print for PhotoCraft system printing 2026-10-12 02:28:40 +02:00
Valentin Klein c450e755ae PNG export: Indexed Color keeps the document resolution (#3047) (#3271)
The palette PNG encoder never wrote a pHYs chunk, so an Indexed Color
document saved as PNG reopened at 72 ppi while the RGB path kept its
resolution. encode_png_indexed now takes an optional dpi and the flat
exporter passes the document resolution under the same embed_metadata
rule as the ordinary PNG encoder. Save for Web PNG-8 is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:12:44 -04:00
Valentin Klein 872d3df2c5 Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113) (#3118)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

* layout_keys: drop unused field, fmt

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 20:11:43 -04:00
Gert Pellin - GPE 5a907b110e test(paint): pin the retouch dab footprint to the Full Size ring (#1761) (#3258)
A dab never paints past radius + 0.5 px from its centre, at any hardness:
the half pixel is the anti-aliased edge of dab_coverage. A hard tip does
reach the ring; softer tips fade out inside it, which is the falloff
working as intended.
2026-10-11 20:02:35 -04:00
Dan Hiorns 599aea0086 Liquify: the brush fades to nothing at its edge at every Density (#2606) (#3241)
A dab's strength was (1 - density) + density * (1 - t^2)^2, so at the
brush rim it stopped at 1 - Density instead of 0: half strength at the
default Density 50, a hard-edged disc at 0. Forward Warp then moved the
pixels just inside the brush and left those just outside, tearing the
image along every dab's circle; with a dab every fifth of the radius
the tears stacked into the sawtooth edges and stripes in #2606. Density
was also backwards: 0 gave the strongest, flattest brush.

`falloff` now blends (1 - t^2)^3 (Density 0: the effect in the centre,
feathered) toward (1 - t^4)^2 (Density 100: most of the brush), both 1
at the centre and 0 with a flat slope at the rim, as Photoshop's
Density reads. Every Liquify brush tool and Freeze/Thaw use it.

Replaying saved Liquify strokes (the command, smart filters) now gives
the smooth result for the same strokes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:01:39 -04:00
Valentin Klein f1059f1885 Shortcuts: layout-independent brush keys, Photoshop's German keys plus a fallback for all layouts (#3109) (#3112)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

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

* layout_keys: drop unused field, fmt

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

* Drop shortcuts::layout_symbols left by the merge: layout_keys::apply replaces it

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:55:11 -04:00
Fabian Ghenof 14733b5223 Moving type keeps its rendering: Artboards to Files no longer drops PSD text (#3218)
translate_layer re-rendered a type layer on every whole-pixel move. For type
from a PSD that replaced Photoshop's rendering with PhotoCraft's: paragraphs
re-wrapped, and text in a font that isn't installed vanished. Artboards to
Files moves each board to the origin this way, so a Steam store template's
"How to use" artboard lost its "Hello!" title. The Move tool, nudges, layer
comps and moving artboards went through the same path.

A whole-pixel move now shifts the rendered pixels, as smart objects and the
Move tool's live preview already do, and moves the TySh position with
photocraft_text::psd::translate_tysh: only the transform's translation
bytes change, so the rest of Photoshop's type data stays byte for byte. Type
with nothing rendered yet is still rendered.

On that template, 17 of 20 artboards now match Photoshop's own composite
exactly (the "How to use" board went from a mean difference of 4.5 to 0.02);
the remaining differences are shape layers (same cause, separate path) and
pre-existing 1-level rounding on two smart objects.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:46:29 -04:00
Valentin Klein 4ab61338ca Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102) (#3106)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:45:42 -04:00
Gert Pellin - GPE 886372812d automation: doc.place reads a file through the read root (#2810) (#3215)
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.

Co-authored-by: switch87 <8284204+switch87@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:33:29 -04:00
Valentin Klein 42ee0d9675 Content-Aware Scale: transform box with live preview (#3096) (#3103)
* Content-Aware Scale: transform box with live preview (#3096)

Edit > Content-Aware Scale opened a parameter dialog. Photoshop has no
dialog: it puts a scale-only transform box on the layer (or the
selection's bounds). The menu now opens that box.

- The box reuses Free Transform. Corner and edge drags scale and an
  inside drag moves; dragging outside does not rotate, Cmd does not
  skew or distort, and the Warp switch and the Transform modes are off.
- The options bar shows X/Y and W/H, then Amount, Protect (an alpha
  channel) and Protect skin tones.
- The live preview runs the command's own carving
  (ContentAwareScalePreview) on a 384 px copy on a worker thread. A
  run for a box that has changed since is cancelled after 150 ms, and
  the latest result is drawn through the box.
- Enter runs edit.contentAwareScale with the box's size and its new
  x/y params as one history step; Esc cancels.
- With params the command still runs directly.

Scorecard TOOL-214-7: done.

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

* Content-Aware Move's transform box: set the new content_aware field

#3095 (Transform On Drop) builds a TransformSession by hand; this PR adds
content_aware to it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:33:15 -04:00
Dan Hiorns c18ee8c88b Tests: one GPU harness at a time in every GPU test binary (WARP crash) (#3212)
On Windows, GPU UI test binaries crashed with STATUS_ACCESS_VIOLATION
whenever two of their tests ran at once: arbitrary_rotation_preview 3
runs in 4 on main, and color_managed_canvas, crop_default_mode,
drag_preview_canvas and full_res_preview at random. egui_kittest
prefers a CPU adapter, which on Windows is WARP over DX12, and a dump
puts the fault inside d3d10warp.dll with two WARP devices rendering on
parallel test threads. Bare egui harnesses with no PhotoCraft code
crash the same way, and Vulkan or GL never does. CI's Windows runners
have no GPU, so they run on WARP too.

Three binaries already held a per-binary lock for the same class of
driver bug (Mesa llvmpipe and RADV, #194; libvulkan with NVIDIA and
lavapipe). That lock now lives once in photocraft-testkit
(`gpu_lock`, with the reasons) and every test in the twelve GPU test
binaries without it takes it on its first line. Under nextest each
test is its own process and the lock is free.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:27:26 -04:00
imSlicedBread 92baf43103 Preserve whitespace in OpenRaster layer names (#3207) 2026-10-11 19:17:06 -04:00
optimalbytee aa1febaffb Keep fitted Camera Raw colour during tuning and crop Nikon D5100 borders (#3201)
* raw: fit non-DNG colour to the camera's embedded JPEG

* raw: preserve fitted Camera Raw look and crop D5100 borders

---------

Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:12:19 -04:00
Valentin Klein d706acdde9 Numeric fields: typed units, scrubby labels, double-click reset (#3099) (#3100)
* Numeric fields: typed units, scrubby labels, double-click reset (#3099)

- Value fields convert a unit typed after a number into the field's unit
  (1in, 2.54cm, 72pt, 1pica/pc, 10px, 50%) at the active document's
  resolution; Image Size and New Document use their own resolution. A
  unit that can't convert, or bad arithmetic, keeps the old value.
- widgets::scrub_label: dragging a label scrubs the value field right
  after it on its row (a step a point, Shift x10, Alt a tenth as fast),
  with a horizontal-resize cursor. Used by options-bar labels, slider
  rows and the Character panel icons. A slider row reports the scrub as
  one drag, so adjustment edits coalesce into one history step.
- Adjustment slider rows with a known default and filter dialog sliders
  reset on double-click, like Color Balance already did.

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

* Scrubby labels: drag-only, not focusable, so Tab skips them

Sense::drag() is focusable in egui 0.36, so every scrubby label became an
extra Tab stop and tab_focus_reveals_the_final_narrow_tool_option could no
longer reach the last tool option.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 19:09:08 -04:00
Valentin Klein 661ba806d1 Content-Aware Move: Transform On Drop (#3071) (#3095)
* Content-Aware Move: Transform On Drop (#3071)

The Content-Aware Move tool gains Photoshop's Transform On Drop checkbox. With it on, the drop
puts a transform box on the moved patch (the Free Transform box's handles, limited to move,
scale and rotate); Enter, the check button or a double-click computes the content-aware blend
with the transformed patch, Esc or the cancel button drops the whole move. The document is
untouched until the commit, so the move stays one History step.

Engine: paint.contentAwareMove takes scaleX / scaleY (%, 1..10000, negative flips) and rotate
(degrees, clockwise) about the selection's centre before the offset, validated (Err, not a
panic, for non-finite, out-of-range or off-canvas placements). The content and the selection's
shape are resampled through the inverse map on float samples (any depth); the selection is
transformed with the content. A plain move keeps the old, exact code path.

UI: cam_transform.rs holds the box (UiState.cam_transform, view state); the option is remembered
in ToolOptions and settable with ui.set camTransformOnDrop; ui.inspect reports camTransform.

Scorecard TOOL-213-4 stays partial: no live preview of the blended result yet.

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

* Tests: keep Transform On Drop cases in the Content-Aware Move test, not Patch (#3071)

paint.patch takes no scaleX/scaleY/rotate, so those cases (which only
paint.contentAwareMove rejects) failed patch_fails_gracefully. They are
already covered by content_aware_move_fails_gracefully.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:44:10 -04:00
Valentin Klein 8584e5dbf8 Actions: Last Filter is recorded as the filter it repeated (#3089) (#3091)
filter.lastFilter was journaled as itself, so a recorded Ctrl+F step
looked up the playing session's last filter: it failed in a new session
("no filter applied yet") and applied whatever filter ran last otherwise.
dispatch now runs Last Filter as the filter it repeats, so that filter is
journaled, recorded, faded and run as a job, as Transform Again records
its transform (#1440).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:28:51 -04:00
Scott C Hughes 4e334b479b test(gradient): guard the on-canvas midpoint diamond drag (#3172) (#3512)
The midpoint diamond's hit-test and drag were untested, so a regression there would go
unnoticed. Add a repro that drags the diamond and asserts the midpoint follows (and no stop
is added). The path works on current main; this is coverage, not a behaviour change.
2026-10-11 18:20:26 -04:00
Valentin Klein f0d3156ad6 Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085) (#3087)
* Tools: Freeform Pen and Curvature Pen (#1054)

The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen.

Freeform Pen: a drag draws freehand; on release the trace is fitted with
cubic Beziers within the options bar's Curve Fit (default 2 px) by
photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for
Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting
Make Work Path already uses), keeping turns sharper than 60 degrees as
corners. Releasing on the start point closes the path.

Curvature Pen: each click adds a smooth point and the path runs through
every point (Catmull-Rom style handles from
photocraft_vector::edit::curvature_knots); dragging a point moves it,
double-clicking toggles corner/smooth, clicking the first point closes,
Enter/Esc finish and Backspace removes the last point, as with the Pen.

Both commit through the Pen's path: a shape layer in Shape mode, else the
targeted vector mask, else the work path, as one History step.

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

* Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085)

The Freeform Pen now draws through lasso_ui, as the Lasso does: Alt held at
the release keeps the path open, each Alt-click adds a straight segment,
releasing Alt with the button down goes on freehand, with it up finishes
the path; clicking the start point closes it. freeform_finish fits each
freehand run on its own and joins the runs with straight segments between
corner anchors. Cmd/Ctrl with the Freeform or Curvature Pen is the
temporary Direct Selection, as with the Pen (#790); it finishes a path in
progress first.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:16:39 -04:00
Scott C Hughes 3455234537 fix(crop): the crop box's own Undo/Redo (#3145) (#3510)
While a crop is pending, Edit > Undo/Redo now steps the *box*, not the document, as
Photoshop does. The frame's history lives on CropState; crop_ui::is_enabled/intercept route
the commands for every caller (menu, shortcut, control channel, MCP), and committing or
cancelling a crop clears it.

The ratio-field input is PR #3175. The handle-drag jitter is left as-is on main (whose
live crop-handle snapping, snap_ui::live_crop_handle, deliberately redesigns that path).

Test: edit_undo_steps_the_crop_box_not_the_document. cargo test -p photocraft-ui-egui --lib crop: 101 pass.
2026-10-11 18:10:38 -04:00
optimalbytee da3f5ffd54 Optimise computed brush coverage and native raster row writes (#3098)
* Speed up raster writes with contiguous sample rows

* Specialise coverage rasterisation for computed brush tips

---------

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
2026-10-11 18:08:44 -04:00
Valentin Klein 0142b24fcb Artboards: a board moved past the canvas origin grows the canvas left/up (#3070) (#3073)
fit_canvas only grew the canvas to the boards' right/bottom edges, so a
board moved left of or above the origin (Move tool drag, layer.translate,
layer.artboard.set with negative x/y) stayed partly off-canvas. It now
shifts the whole document (boards, contents, guides, paths...) right/down
by the overhang via image_cmds::translate_doc and grows the canvas, inside
the move's undo step, like Photoshop's artboard documents.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:05:58 -04:00
kayrockscreenprinting 2b2bb45870 Color Picker: no bigger than Photoshop's (532 × 376 points, was 682 × 437), buttons sized to their labels (#3500)
* Color Picker: no bigger than Photoshop's (532 x 376 points, was 682 x 437)

A 200-point colour field, 42-point component fields, buttons at their
width rather than stretched, smaller new/current swatches and tighter
spacing. Every control stays; the 16-point window padding stays.

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

* Color Picker: actions as wide as the longest label, not the column (133 points, was 160)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:03:29 -04:00
Timothy Daly 4fe6dc6764 Layers: the New Layer dialog (Layer › New › Layer…, ⇧⌘N) (#3104)
* Layers: clip groups follow New Layer, Arrange, Reorder and Delete (#2203)

Adding, moving or deleting layers never touched Layer::clipped, so a
new layer above a clipping base split its group and became the base of
the layers clipped above it, and moving or deleting a base left its
clipped layers clipping to whatever ended up below them.

The new clip_group module applies Photoshop's rules, measured on #2203:
a layer added or moved joins the clip group it lands in; a clipped
layer moved stays clipped while it still sits on a clip group; the
layers clipped to a base that moves away or is deleted are released;
and a clipped run left with nothing below it is released. They are
applied by layer.new.layer (new clipToPrevious param, Photoshop's Use
Previous Layer to Create Clipping Mask), layer.arrange.*, layer.moveTo
(Layers panel drags, batches and ⌥-drag copies) and layer.delete.

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

* Layers: the New Layer dialog (Layer › New › Layer…, ⇧⌘N)

Layer › New › Layer… made a layer straight away. It now opens
Photoshop's New Layer dialog: Name (focused and selected), Use Previous
Layer to Create Clipping Mask, Color, Mode, Opacity, and Fill with
<mode>-neutral color, greyed out with "(No neutral color exists for
<mode> mode.)" for modes that have none. OK makes the layer in one
history step.

layer.new.layer gains mode, opacity, color and fillNeutral params
(new engine module new_layer.rs) beside clipToPrevious. The neutral
colors (white for the darkening modes and Divide, black for the
lightening and difference modes, 50% gray for the light modes) are
checked by compositing at 8, 16 and 32 bits.

As in Photoshop, the Layers panel's New Layer button still makes a
layer at once and ⌥-click opens the dialog; commands with params
(automation, Actions, MCP) never show it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:01:12 -04:00
David Mann b26256faee Type: triple-click selects a line, quadruple a paragraph, five clicks all text (#3493) (#3496)
While editing type, a run of clicks at one spot now selects like Photoshop:
two clicks the word, three the laid-out line (a column in vertical type),
four the paragraph up to its hard return, five all text; the next quick
click starts over with a caret. egui stops counting at three, so the Type
tool keeps its own count per layer (next_click_count) and the ranges come
from a pure helper over the text and its layout (click_selection).

Scorecard TYPE-218-8 is done; docs/gaps.md G8 updated.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:55:18 -04:00
Valentin Klein d7e2f171f3 Actions: a command that runs other commands is recorded once (#3067) (#3072)
* Actions: a command that runs other commands is recorded once (#3067)

A journaled command that called Session::execute journaled the inner
calls as well as its own entry, so a recorded action replayed them twice
(Straighten Layer: rotate + crop, then Straighten again). dispatch now
remembers the journal length when a command starts; when a replayable
command succeeds its entry replaces the entries of the commands it ran.
Edit > Transform > Again is not replayable and keeps its concrete transform.

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

* Actions: Again on a Copy records its steps; taken-back steps are dropped (#3067)

- edit.transform.againCopy is not replayable, like Transform Again (#1440):
  it records layer.duplicate + edit.transform, which play in a session
  that never ran a transform. With one layer selected the duplicate names
  no layer id, so it plays on any document.
- compound, Again on a Copy and Frame from Layers take back what they ran
  when they fail; they now also drop those commands from the journal, so
  recording does not replay an undone edit.
- Recording pauses while a replayable command runs: a script that plays an
  action no longer flushes its earlier steps into the recording and loses
  its own entry.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:53:25 -04:00
David Mann d523405627 scorecard: FILE-216-5 save-path tests are in the tree (#3476)
Every existing save path (layered TIFF, print PDF, ACO/ASE, .shc, Smart
Filters in PSD, atomic-save failure) already has a save-and-read-back
test; SVG and Photoshop PDF have no writer yet (FILE-215-10).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:48:52 -04:00
kotulsky cc772a03c1 fix(export): quick-export every selected layer as a separate PNG (#3462)
Enable Layers context-menu Quick Export as PNG for a multi-layer selection. Capture each selected source before showing one destination prompt and write separate numbered PNG siblings with sanitized layer names, without silently replacing existing sibling files.

Add actual PNG encoding and cancellation regressions, preserve the captured selection when the dialog is open, and update context-menu enablement tests.

Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:48:03 -04:00
Valentin Klein 8f7cf009bb Type Mask: Shift/Alt at commit add to or subtract from the selection; no Layers row for the working text (#3036) (#3069)
* Tools: Horizontal and Vertical Type Mask (#1055)

The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

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

* Type Mask: Shift/Alt at commit combine with the selection; no Layers row for the working text (#3036)

Committing a Type Mask with Shift held (click outside, Cmd+Shift+Enter or
the check button) adds the glyphs to the selection, Alt subtracts them and
Shift+Alt intersects, as in Photoshop; without modifiers it still replaces.
The Layers panel no longer lists the hidden working text layer while a
Type Mask session is open (layer_tree_ui::panel_rows).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:43:56 -04:00
Timothy Daly 5e5a0f6d2d Layers: clip groups follow New Layer, Arrange, Reorder and Delete (#2203) (#3090)
Adding, moving or deleting layers never touched Layer::clipped, so a
new layer above a clipping base split its group and became the base of
the layers clipped above it, and moving or deleting a base left its
clipped layers clipping to whatever ended up below them.

The new clip_group module applies Photoshop's rules, measured on #2203:
a layer added or moved joins the clip group it lands in; a clipped
layer moved stays clipped while it still sits on a clip group; the
layers clipped to a base that moves away or is deleted are released;
and a clipped run left with nothing below it is released. They are
applied by layer.new.layer (new clipToPrevious param, Photoshop's Use
Previous Layer to Create Clipping Mask), layer.arrange.*, layer.moveTo
(Layers panel drags, batches and ⌥-drag copies) and layer.delete.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:43:30 -04:00
kotulsky 7c2e8c3d95 fix(actions): fill the resizable panel with the step list (#3460)
Remove the 320-point list height cap and nested dock scrolling. Reserve space for the footer while filling the available panel height.

Add a panel-height regression test and check recording autoscroll against the visible footer instead of a fixed coordinate.

Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
2026-10-11 17:41:04 -04:00
562 changed files with 66126 additions and 4481 deletions
+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 }
+7
View File
@@ -21,3 +21,10 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash packaging/linux/verified-download-test.sh
+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
+1268 -129
View File
File diff suppressed because it is too large Load Diff
+40 -11
View File
@@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.8.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
@@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
craft-print = { git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "7fcee66b8eef8eac3c7644fd024e1f1b051f5a3e" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -50,7 +51,9 @@ zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
egui = "0.36"
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -81,13 +84,14 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI shell, and "s" for
# other crates except pixel processing, which keeps opt-level 3 for filters and compositing. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -95,11 +99,35 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# 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"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
@@ -112,3 +140,4 @@ opt-level = 3
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+61 -13
View File
@@ -15,19 +15,21 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
@@ -45,7 +47,7 @@ USAGE:
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
@@ -67,17 +69,17 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
@@ -213,6 +215,10 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
if let Some(b) = a.get("--dpx-bits") {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
Ok(o)
}
@@ -240,7 +246,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 +298,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 +318,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 +350,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(())
@@ -384,6 +398,38 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
@@ -402,8 +448,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
+111 -1
View File
@@ -189,9 +189,11 @@ fn run_commands_and_save() {
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["name"], "Run");
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
assert_eq!(names, ["Ink", "Background"]);
// Continue from the saved file and export PNG.
@@ -201,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();
@@ -409,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
let out = d.join("a.dpx");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--dpx-bits", b]);
}
ok(&mut convert);
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
assert!(back.exists());
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
@@ -638,3 +718,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
assert!(!err.contains("panicked"), "{err}");
}
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
/// PhotoCraft can't play are warned about up front.
#[test]
fn batch_plays_a_photoshop_actions_file() {
use photocraft_engine::actions_cmds::Action;
let d = tmp("atn");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
assert!(skipped.is_empty());
let atn = d.join("Set.atn");
std::fs::write(&atn, bytes).unwrap();
let out_dir = d.join("out");
// Two actions: the set needs --action.
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
+7 -2
View File
@@ -125,14 +125,19 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path must match.
// Everything but the file's own path and saved document name must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
v.as_object_mut().unwrap().remove("path");
v
};
let (ia, ib) = (info(&a), info(&b));
let (mut ia, mut ib) = (info(&a), info(&b));
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia["name"], "a.pcraft");
assert_eq!(ib["name"], "b.pcraft");
ia.as_object_mut().unwrap().remove("name");
ib.as_object_mut().unwrap().remove("name");
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+19 -21
View File
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
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", "af", "afdesign", "afphoto", "afpub",
"ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr",
"grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -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);
@@ -352,6 +347,7 @@ fn services(inbox: Inbox) -> Services {
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
@@ -382,6 +378,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());
+2 -1
View File
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -64,6 +64,7 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
+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();
}
}
+49 -2
View File
@@ -90,11 +90,21 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
window_builder: Some(Box::new(at_least_min_size)),
window_builder: Some(Box::new(restored_window)),
..Default::default()
}
}
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
/// Full Screen did nothing until `F` had cycled through Standard.
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
at_least_min_size(builder).with_fullscreen(false)
}
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
@@ -230,6 +240,8 @@ fn main() -> eframe::Result {
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
@@ -297,6 +309,9 @@ fn main() -> eframe::Result {
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
#[cfg(target_os = "linux")]
let monitor = monitor_profile::detect_async_for_session(native_session);
#[cfg(not(target_os = "linux"))]
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
@@ -379,7 +394,14 @@ fn main() -> eframe::Result {
// the right profile; a slower reading is applied when it arrives, and the shell reads
// again when the displays may have changed.
if let Some(rx) = monitor {
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
#[cfg(target_os = "linux")]
{
app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session)));
}
#[cfg(not(target_os = "linux"))]
{
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
}
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
@@ -612,6 +634,22 @@ mod tests {
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
let saved = super::native_options(super::CUSTOM_TITLEBAR)
.viewport
.with_inner_size([2560.0, 1392.0])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true);
let opened = hook(saved);
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
@@ -632,4 +670,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));
}
}
+246 -6
View File
@@ -6,7 +6,12 @@
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
//! thread; the shell starts one at launch and again when the displays may have changed
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices
//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has
//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session,
//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland
//! image description, so applying the output ICC in PhotoCraft could double-transform it.
//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
@@ -16,15 +21,33 @@ use photocraft_engine::display_color::Display;
pub type Detection = Result<Vec<Display>, String>;
/// Starts reading the displays in the background; `None` when the platform has no reader.
#[cfg(not(target_os = "linux"))]
pub fn detect_async() -> Option<Receiver<Detection>> {
if !cfg!(target_os = "macos") {
return None;
#[cfg(target_os = "macos")]
{
Some(spawn_reader(detect))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// Linux display profile reads are supported only for an X11 session. In particular, an app
/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a
/// compositor-managed output profile a second time.
#[cfg(target_os = "linux")]
pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option<Receiver<Detection>> {
(session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect))
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
Some(rx)
rx
}
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
@@ -110,9 +133,225 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
fn detect() -> Detection {
Err("this platform doesn't report display profiles".into())
linux::detect()
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::{BTreeMap, HashMap};
use std::io::Read as _;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use photocraft_engine::display_color::Display;
use x11rb::connection::Connection as _;
use x11rb::protocol::randr;
use zbus::blocking::{connection::Builder, proxy::Proxy};
use zbus::zvariant::OwnedObjectPath;
use super::Detection;
const MAX_ICC_BYTES: usize = 64 * 1024 * 1024;
const COLORD: &str = "org.freedesktop.ColorManager";
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
struct IccProfile {
name: String,
bytes: Arc<Vec<u8>>,
}
pub(super) fn detect() -> Detection {
let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root;
let resources = randr::get_screen_resources_current(&connection, root)
.map_err(|e| format!("couldn't request XRandR outputs: {e}"))?
.reply()
.map_err(|e| format!("couldn't read XRandR outputs: {e}"))?;
let mut output_names = HashMap::new();
let mut active_crtcs = BTreeMap::new();
for output in resources.outputs {
let info = match randr::get_output_info(&connection, output, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) => info,
Err(e) => {
log::debug!("couldn't read one XRandR output: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request one XRandR output: {e}");
continue;
}
};
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
continue;
}
let Ok(name) = String::from_utf8(info.name) else {
log::debug!("skipping an XRandR output with a non-UTF-8 name");
continue;
};
if !name.is_empty() {
output_names.insert(output, name);
active_crtcs.insert(info.crtc, ());
}
}
let profiles = match colord_profiles() {
Ok(profiles) => profiles,
Err(e) => {
log::warn!("couldn't read X11 display profiles from colord: {e}");
HashMap::new()
}
};
let mut displays = Vec::with_capacity(active_crtcs.len());
for crtc in active_crtcs.into_keys() {
let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info,
Ok(_) => continue,
Err(e) => {
log::debug!("couldn't read XRandR CRTC {crtc}: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request XRandR CRTC {crtc}: {e}");
continue;
}
};
let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::<Option<Vec<_>>>() else { continue };
let Some(primary_name) = output_names.first() else { continue };
let profile = unambiguous_clone_profile(&output_names, &profiles);
displays.push(Display {
id: crtc,
name: primary_name.clone(),
frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)],
profile_name: profile.as_ref().map(|p| p.name.clone()),
icc: profile.map(|p| p.bytes),
});
}
if displays.is_empty() {
return Err("X11 reported no active RandR outputs".into());
}
Ok(displays)
}
fn colord_profiles() -> Result<HashMap<String, Vec<IccProfile>>, String> {
let connection = Builder::system()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?;
let devices: Vec<OwnedObjectPath> =
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?;
let mut profiles: HashMap<String, Vec<IccProfile>> = HashMap::new();
for device_path in devices {
let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?;
let metadata: HashMap<String, String> = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?;
let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue };
// `*` is colord's default profile qualifier. A display without one is kept, with no
// ICC, so the engine reports the normal sRGB fallback.
let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) {
Ok(path) => path,
Err(_) => continue,
};
let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?;
let filename: String = match profile.get_property::<String>("Filename") {
Ok(filename) if !filename.is_empty() => filename,
_ => continue,
};
let Some(profile) = read_icc(Path::new(&filename)) else {
log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}");
continue;
};
profiles.entry(output_name.clone()).or_default().push(profile);
}
Ok(profiles)
}
fn read_icc(path: &Path) -> Option<IccProfile> {
let mut bytes = Vec::new();
std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
let bytes = checked_icc_bytes(bytes)?;
let name = path.file_name()?.to_str()?.to_owned();
Some(IccProfile { name, bytes: Arc::new(bytes) })
}
fn checked_icc_bytes(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
return None;
}
let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize;
if declared < 132 || declared > bytes.len() {
return None;
}
bytes.truncate(declared);
let profile = photocraft_cms::Profile::parse(&bytes).ok()?;
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap<String, Vec<IccProfile>>) -> Option<IccProfile> {
let mut selected: Option<&IccProfile> = None;
for output in output_names {
let candidates = profiles.get(output)?;
let first = candidates.first()?;
if candidates.iter().any(|candidate| candidate.bytes != first.bytes) {
return None;
}
if selected.is_some_and(|profile| profile.bytes != first.bytes) {
return None;
}
selected = Some(first);
}
selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes));
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none());
assert!(checked_icc_bytes(vec![0; 132]).is_none());
assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none());
}
#[test]
fn cloned_outputs_use_only_one_unambiguous_profile() {
let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes();
let profile = |name: &str, bytes: std::sync::Arc<Vec<u8>>| IccProfile { name: name.into(), bytes };
let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()];
let matching =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into()));
let differing =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &differing).is_none());
let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &missing).is_none());
let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]);
assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none());
}
}
}
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
@@ -285,6 +524,7 @@ mod tests {
}
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn detected_displays_parse() {
if let Ok(displays) = detect() {
+88 -16
View File
@@ -48,6 +48,66 @@ fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
#[cfg(target_os = "linux")]
const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024;
#[cfg(target_os = "linux")]
const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024;
#[cfg(target_os = "linux")]
fn icc_profile(bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES {
return None;
}
let profile = match photocraft_cms::Profile::parse(&bytes) {
Ok(profile) => profile,
Err(_) => return None,
};
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
#[cfg(target_os = "linux")]
fn icc_profile_atom<C: x11rb::connection::RequestConnection>(conn: &C, name: &str) -> Option<u32> {
use x11rb::protocol::xproto::ConnectionExt as _;
let cookie = match conn.intern_atom(true, name.as_bytes()) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let atom = match cookie.reply() {
Ok(reply) => reply.atom,
Err(_) => return None,
};
(atom != 0).then_some(atom)
}
#[cfg(target_os = "linux")]
fn output_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, output: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum};
let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.data)
}
#[cfg(target_os = "linux")]
fn root_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, root: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.value)
}
#[cfg(target_os = "linux")]
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
if wayland {
return portal(stop);
@@ -55,10 +115,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
use x11rb::{
connection::Connection,
image::{Image, PixelLayout},
protocol::{
randr::ConnectionExt as _,
xproto::{AtomEnum, ConnectionExt, VisualClass},
},
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
};
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
@@ -71,13 +128,16 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
{
first.primary = true;
}
let regions: Vec<(i16, i16, u16, u16, bool)> =
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
let regions: Vec<(i16, i16, u16, u16, bool, Vec<x11rb::protocol::randr::Output>)> = if monitors.is_empty() {
vec![(0, 0, w, h, true, Vec::new())]
} else {
monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect()
};
if regions.len() > 16 {
return Err("Too many screens for the color picker".into());
}
let mut total = 0usize;
for &(x, y, mw, mh, _) in &regions {
for &(x, y, mw, mh, _, _) in &regions {
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
return Err("Invalid X11 display geometry".into());
}
@@ -92,20 +152,20 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
return Err("Screen picking requires an X11 TrueColor visual".into());
}
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
let primary_profile = if atom == 0 {
None
} else {
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
};
let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
let mut images = Vec::new();
for (x, y, mw, mh, primary) in regions {
for (x, y, mw, mh, primary, outputs) in regions {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| {
if !primary {
return None;
}
icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom))
});
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
for row in 0..mh {
for col in 0..mw {
@@ -120,7 +180,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
width: usize::from(mw),
height: usize::from(mh),
rgba,
profile: if primary { primary_profile.clone() } else { None },
profile,
});
}
Ok(Capture::Images(images))
@@ -359,6 +419,18 @@ mod tests {
}
#[cfg(target_os = "linux")]
#[test]
fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(icc_profile(bytes.clone()), Some(bytes));
assert!(icc_profile(Vec::new()).is_none());
assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none());
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(icc_profile(cmyk.as_ref().clone()).is_none());
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
fn screen_capture_reads_a_known_x11_fixture_pixel() {
use x11rb::{
+35 -10
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", "dpx", "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)),
]
}
@@ -61,6 +62,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -115,6 +117,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);
@@ -191,6 +200,11 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
@@ -204,25 +218,36 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
opts
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush.
const ENCODE_SHARE: f32 = 0.85;
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
/// once writing starts the save can't be cancelled.
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
fn save_through(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
write: impl Fn(&str, &[u8]) -> Result<(), String>,
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
) -> Result<Vec<String>, String> {
ctl.progress(0.0, "Encoding");
let opts = export_options(settings);
let r = crate::crash_guard::guard("Save", || photocraft_io::export(doc, path, &opts).map_err(|e| e.to_string()))?;
let cancelled = || ctl.check().is_err();
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
ctl.commit()?;
ctl.progress(0.0, "Writing");
write(path, &r.bytes)?;
ctl.progress(ENCODE_SHARE, "Writing");
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
ctl.progress(1.0, "");
Ok(r.warnings)
}
@@ -232,7 +257,7 @@ fn save_file(
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
) -> Result<Vec<String>, String> {
save_through(doc, path, settings, ctl, |p, b| write_atomic(Path::new(p), b))
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
@@ -341,7 +366,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
});
let automation_save = automation.clone().map(|workspace| {
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
save_through(doc, path, settings, ctl, |p, b| workspace.write(p, b).map_err(|e| e.to_string()))
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
}) as photocraft_ui_egui::SaveFileFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
+12 -6
View File
@@ -7,10 +7,15 @@ Automation is a privilege boundary because requests can cause filesystem access,
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
are limited to 16, and headless/MCP batches to 256 steps.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless
JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is
enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because
its request ID cannot safely be parsed. Active TCP connections are limited to 16, and
headless/MCP batches to 256 steps.
- Capability-scoped automation file opens read in chunks without reserving from the file's
metadata length. A read rejects data beyond the length observed on the opened file, so growth
after the metadata check cannot expand the read. Large PSB files have no fixed automation file
size ceiling; their materialized bytes and decoded document still require available memory.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
@@ -49,8 +54,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion
limit), aggregate document/session-memory accounting, compositor scratch-space accounting,
or command-duration/cancellation budget;
- one thread per accepted TCP connection;
- no structured security audit event stream;
- desktop screenshot capture/encoding and document import/export have no automation-specific
+1 -1
View File
@@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem
### Native format
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
## Review checklist
+24
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,28 @@ 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
[people.aweussom]
real_name = "Tommy Leonhardsen"
[people.Furash]
sync_github_name = true
[people.lucAmbr0]
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));
}
}
+205 -93
View File
@@ -30,7 +30,12 @@
use photocraft_geom::Rect;
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked};
use std::borrow::Cow;
use photocraft_raster::{Cancelled, Interrupt};
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked, complete_masked_owned};
use crate::par;
use crate::poisson::membrane_fill_with;
/// Options for [`fill`].
@@ -143,7 +148,8 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
}
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
/// `Err` when cancelled.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
let Src { w, h, ch, .. } = src;
let (s, c) = (-deg.to_radians()).sin_cos();
// Output size: the rotated, scaled bounding box.
@@ -153,8 +159,12 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
let mut px = vec![0.0f32; ch];
for y in 0..oh {
// Every pixel is sampled on its own, so the rows can be done in any order.
let row = |y: usize, irow: &mut [f32], orow: &mut [bool]| {
if ctl.cancelled() {
return;
}
let mut px = vec![0.0f32; ch];
for x in 0..ow {
let (dx, dy) = ((x as f32 - ocx) / scale, (y as f32 - ocy) / scale);
let mut sx = icx + dx * c - dy * s;
@@ -163,13 +173,14 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
sx = (w as f32 - 1.0) - sx;
}
if sample(src, sx, sy, &mut px) {
let i = y * ow + x;
out.ok[i] = true;
out.img[i * ch..(i + 1) * ch].copy_from_slice(&px);
orow[x] = true;
irow[x * ch..(x + 1) * ch].copy_from_slice(&px);
}
}
}
out
};
par::chunks2_mut(&mut out.img, ow * ch, &mut out.ok, ow, true, row);
ctl.check()?;
Ok(out)
}
/// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole).
@@ -191,81 +202,136 @@ pub fn fill_with(
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
fill_cow(w, h, ch, Cow::Borrowed(img), hole, source, opts, ctl)
}
/// [`fill_with`] that fills `img` itself rather than a copy of it (a big window need not be held
/// twice). The result differs from `img` only in the hole.
#[allow(clippy::too_many_arguments)]
pub fn fill_owned(
w: usize,
h: usize,
ch: usize,
img: Vec<f32>,
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
fill_cow(w, h, ch, Cow::Owned(img), hole, source, opts, ctl)
}
#[allow(clippy::too_many_arguments)]
fn fill_cow(
w: usize,
h: usize,
ch: usize,
img: Cow<[f32]>,
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
assert_eq!(source.len(), w * h);
if !hole.iter().any(|h| *h) {
return Ok(img.to_vec());
return Ok(img.into_owned());
}
let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() };
let gap = 2 * params.patch_radius + 2;
let ok: Vec<bool> = hole.iter().zip(source).map(|(h, s)| !h && *s).collect();
let src = Src { w, h, ch, img, ok: &ok };
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
let src = Src { w, h, ch, img: &img, ok: &ok };
// Variants: the identity (which holds the hole) first, then the adaptations.
let mut extra: Vec<Variant> = Vec::new();
if opts.mirror {
extra.push(variant(src, 0.0, 1.0, true));
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
}
for &deg in &opts.rotations {
extra.push(variant(src, deg, 1.0, false));
extra.push(variant(src, deg, 1.0, false, ctl)?);
}
if opts.scale {
extra.push(variant(src, 0.0, 0.8, false));
extra.push(variant(src, 0.0, 1.25, false));
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
}
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = if extra.is_empty() { w } else { w + gap + col_w };
let mh = h.max(col_h);
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
for x in 0..w {
let (s, d) = (y * w + x, y * mw + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
roles[d] = if hole[s] {
Px::Hole
} else if ok[s] {
Px::Source
} else {
Px::Known
};
let role = |s: usize| {
if hole[s] {
Px::Hole
} else if ok[s] {
Px::Source
} else {
Px::Known
}
}
let mut oy = 0;
for v in &extra {
for y in 0..v.h {
for x in 0..v.w {
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
if v.ok[s] {
roles[d] = Px::Source;
}
};
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
// Without variants it is the image itself.
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
(w, h, None, par::map(w * h, true, role))
} else {
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = w + gap + col_w;
let mh = h.max(col_h);
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
for x in 0..w {
let (s, d) = (y * w + x, y * mw + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
roles[d] = role(s);
}
}
oy += v.h + gap;
}
let mut oy = 0;
for v in &extra {
ctl.check()?;
for y in 0..v.h {
for x in 0..v.w {
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
if v.ok[s] {
roles[d] = Px::Source;
}
}
}
oy += v.h + gap;
}
(mw, mh, Some(mimg), roles)
};
drop((extra, ok));
ctl.check()?;
// The completion is nearly all the work.
let cancel = || ctl.cancelled();
let progress = |f: f32| ctl.progress(f * 0.99);
let filled = complete_masked(mw, mh, ch, &mimg, &roles, &params, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
let mut out = img.to_vec();
match filled {
Some(f) => {
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
}
}
let inner = photocraft_raster::Interrupt::new(&cancel, &progress);
let out = match mosaic {
// Without variants the image itself is completed (it changes only in the hole).
None => {
let filled: Result<Vec<f32>, Cow<[f32]>> = match img {
Cow::Owned(v) => complete_masked_owned(w, h, ch, v, &roles, &params, &inner)?.map_err(Cow::Owned),
Cow::Borrowed(b) => complete_masked(w, h, ch, b, &roles, &params, &inner)?.ok_or(Cow::Borrowed(b)),
};
match filled {
Ok(f) => f,
Err(img) => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
}
}
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
}
Some(mimg) => match complete_masked(mw, mh, ch, &mimg, &roles, &params, &inner)? {
Some(f) => {
let mut out = img.into_owned();
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
}
}
}
out
}
None => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
},
};
ctl.check()?;
ctl.progress(1.0);
Ok(out)
@@ -305,36 +371,50 @@ const AUTO_SIDE: usize = 384;
/// pixels that may be copied from (never the hole). Falls back to everything outside the hole
/// when the area would be too small to copy from.
pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec<bool> {
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with};
// Never cancelled; the fallback (everything outside the hole) is unreachable.
auto_sampling_with(w, h, ch, img, hole, &Interrupt::NONE).unwrap_or_else(|_| hole.iter().map(|h| !h).collect())
}
/// [`auto_sampling`] that can be cancelled. `Err(Cancelled)` when `ctl` was cancelled.
pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], ctl: &Interrupt) -> Result<Vec<bool>, Cancelled> {
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_cancellable};
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
let everything: Vec<bool> = hole.iter().map(|h| !h).collect();
let everything = || -> Vec<bool> { par::map(hole.len(), true, |i| !hole[i]) };
if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) {
return everything;
return Ok(everything());
}
// Work at a reduced scale for big windows (the area is smooth at this scale).
let k = w.max(h).div_ceil(AUTO_SIDE).max(1);
let (sw, sh) = (w.div_ceil(k), h.div_ceil(k));
let nc = ch.min(3);
let mut planes = vec![vec![0.0f32; sw * sh]; nc];
let mut shole = vec![false; sw * sh];
let mut cnt = vec![0.0f32; sw * sh];
for y in 0..h {
for x in 0..w {
let i = (y / k) * sw + x / k;
let p = y * w + x;
if hole[p] {
shole[i] = true;
}
for (c, pl) in planes.iter_mut().enumerate() {
pl[i] += img[p * ch + c];
}
cnt[i] += 1.0;
// Each cell sums its k × k pixels, a row of cells at a time (in the same order as one pass
// over the image would).
// Per cell: the sums of its colour planes, its pixel count and whether it holds the hole.
let mut sums = vec![0.0f32; sw * sh * (nc + 2)];
par::chunks_mut(&mut sums, sw * (nc + 2), true, |sy, row| {
if ctl.cancelled() {
return;
}
}
for pl in &mut planes {
pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0));
}
for y in sy * k..((sy + 1) * k).min(h) {
for x in 0..w {
let cell = &mut row[(x / k) * (nc + 2)..(x / k + 1) * (nc + 2)];
let p = y * w + x;
if hole[p] {
cell[nc + 1] = 1.0;
}
for c in 0..nc {
cell[c] += img[p * ch + c];
}
cell[nc] += 1.0;
}
}
});
ctl.check()?;
let cell = |i: usize| &sums[i * (nc + 2)..(i + 1) * (nc + 2)];
let planes: Vec<Vec<f32>> = (0..nc).map(|c| (0..sw * sh).map(|i| cell(i)[c] / cell(i)[nc].max(1.0)).collect()).collect();
let shole: Vec<bool> = (0..sw * sh).map(|i| cell(i)[nc + 1] > 0.0).collect();
drop(sums);
// Features: local means and local contrast (of the luma-ish average of the colour planes).
let r = 3;
let means: Vec<Vec<f32>> = planes.iter().map(|p| box_mean(sw, sh, p, r)).collect();
@@ -354,8 +434,9 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
let ring_w = 6.0f32 / k as f32 + 1.0;
let ring: Vec<usize> = (0..sw * sh).filter(|&i| !shole[i] && dist[i] <= ring_w).collect();
if ring.is_empty() {
return everything;
return Ok(everything());
}
ctl.check()?;
// A few cluster centres of the ring's features (k-means, deterministic start).
let kc = 6.min(ring.len());
let mut f = vec![0.0f32; nf];
@@ -410,20 +491,21 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
// Contrast-sensitive smoothness (the weight of a squared colour difference). The scale of
// "similar" and this weight were fitted to Photoshop's Auto overlays (mean IoU 0.92 on ten
// synthetic and public-domain cases).
let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05);
ctl.check()?;
let area =
grid_cut_cancellable(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05, &|| ctl.cancelled()).ok_or(Cancelled)?;
let mut out = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let i = y * w + x;
out[i] = !hole[i] && area[(y / k) * sw + x / k];
par::chunks_mut(&mut out, w, true, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
*o = !hole[y * w + x] && area[(y / k) * sw + x / k];
}
}
});
// Too little to copy from (fewer source pixels than twice the hole): sample everything.
let n_hole = hole.iter().filter(|h| **h).count();
if out.iter().filter(|o| **o).count() < 2 * n_hole.max(64) {
return everything;
let n_hole = par::count(hole.len(), |i| hole[i]);
if par::count(out.len(), |i| out[i]) < 2 * n_hole.max(64) {
return Ok(everything());
}
out
Ok(out)
}
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
@@ -497,6 +579,36 @@ mod tests {
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
}
#[test]
fn filling_in_place_is_filling_a_copy() {
let (w, h) = (90, 70);
let rgb: Vec<f32> = noise(w, h, 4).iter().enumerate().map(|(i, v)| v * 0.5 + 0.3 * ((i / 3 % w) % 11 < 5) as u8 as f32).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (30..52).contains(&(i % w)) && (25..44).contains(&(i / w))).collect();
let source: Vec<bool> = (0..w * h).map(|i| i % w < 75).collect();
let none = Interrupt::NONE;
for opts in [FillOptions::default(), FillOptions { color_adaptation: Adapt::NONE, mirror: true, ..Default::default() }] {
let copy = fill_with(w, h, 3, &rgb, &hole, &source, &opts, &none).unwrap();
let owned = fill_owned(w, h, 3, rgb.clone(), &hole, &source, &opts, &none).unwrap();
assert_eq!(copy.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), owned.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
// Only the hole changes.
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| owned[i * 3 + c].to_bits() == rgb[i * 3 + c].to_bits())));
}
// Nothing to copy from: the membrane, either way.
let nowhere = vec![false; w * h];
let copy = fill_with(w, h, 3, &rgb, &hole, &nowhere, &FillOptions::default(), &none).unwrap();
assert_eq!(copy, fill_owned(w, h, 3, rgb.clone(), &hole, &nowhere, &FillOptions::default(), &none).unwrap());
}
#[test]
fn auto_sampling_can_be_cancelled() {
let (w, h) = (200, 160);
let img: Vec<f32> = noise(w, h, 5).iter().enumerate().map(|(i, v)| v * 0.1 + if (i / 3) % w < 120 { 0.3 } else { 0.6 }).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (80..110).contains(&(i % w)) && (60..90).contains(&(i / w))).collect();
let yes = || true;
assert!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::cancel_only(&yes)).is_err());
assert_eq!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::NONE).unwrap(), auto_sampling(w, h, 3, &img, &hole));
}
#[test]
fn fills_only_the_hole_with_known_texture() {
let (w, h) = (48, 32);
@@ -671,9 +783,9 @@ mod tests {
let img = vec![0.5f32; 10 * 6];
let ok = vec![true; 60];
let src = Src { w: 10, h: 6, ch: 1, img: &img, ok: &ok };
let v = variant(src, 90.0, 1.0, false);
let v = variant(src, 90.0, 1.0, false, &Interrupt::NONE).unwrap();
assert_eq!((v.w, v.h), (6, 10));
let v = variant(src, 0.0, 0.8, true);
let v = variant(src, 0.0, 0.8, true, &Interrupt::NONE).unwrap();
assert_eq!((v.w, v.h), (8, 5));
assert!(v.ok.iter().filter(|o| **o).count() > 20);
}
+1423 -400
View File
File diff suppressed because it is too large Load Diff
+30 -4
View File
@@ -49,6 +49,7 @@ mod other;
mod other2;
pub mod paint;
pub mod panorama;
mod par;
pub mod perspective;
mod photo_util;
mod pixelate;
@@ -1094,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)
@@ -1178,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);
}
@@ -1201,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);
@@ -1210,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;
@@ -1228,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)]
+266 -58
View File
@@ -116,7 +116,8 @@ pub struct LiquifyStroke {
/// Brush diameter (px).
#[serde(default = "d_size")]
pub size: f64,
/// Edge feathering, 0 (hard) ..100 (soft).
/// How far the full effect reaches toward the brush edge, 0 (only the centre; feathered)
/// ..100 (most of the brush). It always fades to nothing at the edge ([`falloff`]).
#[serde(default = "d_density")]
pub density: f64,
/// Overall strength, 0..100.
@@ -153,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.
@@ -164,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.
@@ -176,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).
@@ -219,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 => {
@@ -305,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 {
@@ -420,8 +497,7 @@ impl LiquifyField {
if t2 >= 1.0 {
continue;
}
let soft = (1.0 - t2) * (1.0 - t2);
let fall = (1.0 - density) + density * soft;
let fall = falloff(t2, density);
let idx = j * self.w + i;
let k_raw = strength * fall;
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
@@ -526,6 +602,23 @@ impl LiquifyField {
}
}
/// A dab's strength at squared distance `t2` (in brush radii, 0..1) for Density `density` (0..1):
/// 1 at the centre, and down to 0 at the rim with a flat slope at every density, as in
/// Photoshop, where Density sets how far the full effect reaches toward the edge (0: only the
/// centre, the rest feathered; 1: most of the brush) but the effect always fades out at it.
/// A brush that stopped short of 0 at its rim moved the pixels inside it and left those just
/// outside, tearing the image along every dab's circle (#2606).
pub fn falloff(t2: f64, density: f64) -> f64 {
let t2 = t2.clamp(0.0, 1.0);
let density = if density.is_nan() { 0.5 } else { density.clamp(0.0, 1.0) };
// Feathered: (1 - t²)³. Dense: (1 - t⁴)², flat in the middle. Both are 0 with zero slope at
// t = 1, and their slopes stay small enough that a dab every fifth of the radius never folds
// the image over itself.
let feathered = (1.0 - t2).powi(3);
let dense = (1.0 - t2 * t2).powi(2);
feathered + (dense - feathered) * density
}
fn bilinear(d: &[[f32; 2]], w: usize, h: usize, gx: f64, gy: f64) -> [f64; 2] {
if !(gx > -1.0 && gy > -1.0 && gx < w as f64 && gy < h as f64) {
return [0.0, 0.0];
@@ -904,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]
@@ -928,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
@@ -938,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] {
@@ -956,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
@@ -1066,6 +1223,57 @@ mod tests {
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
}
#[test]
fn the_brush_fades_to_nothing_at_its_rim_at_every_density() {
for density in [0.0, 0.25, 0.5, 0.75, 1.0, f64::NAN, -3.0, 7.0] {
assert_eq!(falloff(0.0, density), 1.0, "full strength at the centre ({density})");
assert_eq!(falloff(1.0, density), 0.0, "nothing at the rim ({density})");
// Flat at the rim: the last 2 % of the radius moves at most a couple of percent.
assert!(falloff(0.98f64.powi(2), density) < 0.02, "{density}: {}", falloff(0.98f64.powi(2), density));
let mut prev = 1.0;
for k in 1..=100 {
let v = falloff(k as f64 / 100.0, density);
assert!(v <= prev && v >= 0.0, "{density}: not falling at t² = {k}%");
prev = v;
}
}
// Density widens the strong core: more strength at every radius, as in Photoshop.
for k in 1..100 {
let t2 = k as f64 / 100.0;
assert!(falloff(t2, 1.0) >= falloff(t2, 0.5) && falloff(t2, 0.5) >= falloff(t2, 0.0), "t² = {t2}");
}
assert!(falloff(0.5, 1.0) > 0.5 && falloff(0.5, 0.0) < 0.2);
}
/// #2606: the dab's strength used to stop at 1 - Density at its rim, so a Forward Warp moved
/// everything just inside the brush and nothing just outside, tearing the image along every
/// 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 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;
s.points = vec![vec![48.0, 32.0], vec![48.0 + drag, 32.0]];
let f = LiquifyField::from_strokes(bounds(), 1.0, &[s]);
let mut rim = 0;
for j in 0..f.h {
for i in 0..f.w {
let p = f.node_pos(i, j);
let t = (p[0] - 48.0).hypot(p[1] - 32.0) / r;
if (0.97..1.0).contains(&t) {
let d = f.d[j * f.w + i];
assert!(f64::from(d[0].hypot(d[1])) < 0.03 * drag, "density {density}: a node at {t:.3} r moved {d:?}");
rim += 1;
}
}
}
assert!(rim > 20, "{rim} rim nodes");
assert!(f.sample(48.0, 32.0)[0] < -0.9 * drag, "density {density}: the centre follows the brush");
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
@@ -1144,7 +1352,7 @@ mod tests {
assert!(f.max_displacement() < before * 0.2, "{} vs {before}", f.max_displacement());
// Frozen area doesn't move.
let mut fr = LiquifyStroke::new(LiquifyTool::Freeze, 60.0);
fr.density = 0.0;
fr.density = 100.0;
fr.points = vec![vec![40.0, 32.0]];
let mut g = LiquifyField::new(bounds(), 2.0);
g.apply_stroke(&fr);
+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);
}
+77
View File
@@ -0,0 +1,77 @@
//! Data-parallel helpers: rayon on native targets, sequential on wasm (where this crate has no
//! threads). Each gives the same result either way, and whatever `par` says.
/// `f(index, chunk)` for each `len`-item chunk of `buf`, in parallel when `par`.
pub(crate) fn chunks_mut<T: Send>(buf: &mut [T], len: usize, par: bool, f: impl Fn(usize, &mut [T]) + Sync + Send) {
if len == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
buf.par_chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
return;
}
let _ = par;
buf.chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
}
/// [`chunks_mut`] over two buffers in step: the `i`-th `la`-item chunk of `a` with the `i`-th
/// `lb`-item chunk of `b`.
pub(crate) fn chunks2_mut<A: Send, B: Send>(a: &mut [A], la: usize, b: &mut [B], lb: usize, par: bool, f: impl Fn(usize, &mut [A], &mut [B]) + Sync + Send) {
if la == 0 || lb == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
a.par_chunks_mut(la).zip(b.par_chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
return;
}
let _ = par;
a.chunks_mut(la).zip(b.chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
}
/// `[f(0), f(1), …, f(n - 1)]`, in parallel when `par`.
pub(crate) fn map<T: Send>(n: usize, par: bool, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
return (0..n).into_par_iter().map(f).collect();
}
let _ = par;
(0..n).map(f).collect()
}
/// How many of `0..n` satisfy `f`.
pub(crate) fn count(n: usize, f: impl Fn(usize) -> bool + Sync + Send) -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
(0..n).into_par_iter().filter(|&i| f(i)).count()
}
#[cfg(target_arch = "wasm32")]
(0..n).filter(|&i| f(i)).count()
}
/// `f(item)` for each of `items`, in parallel when `par`.
pub(crate) fn each<T: Send>(items: Vec<T>, par: bool, f: impl Fn(T) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
items.into_par_iter().for_each(f);
return;
}
let _ = par;
items.into_iter().for_each(f);
}
/// Threads the parallel helpers can use (1 on wasm).
pub(crate) fn threads() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
1
}
+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] {
+645 -105
View File
@@ -1,10 +1,12 @@
//! Edit › Puppet Warp: pins on a triangle mesh over the layer's opaque region, deformed
//! as-rigidly-as-possible.
//!
//! * **Mesh.** The layer's coverage (alpha above a threshold, grown or shrunk by `expansion`) is
//! rasterized onto a coarse block grid; a square grid at the density's spacing keeps every
//! cell that touches covered blocks, split into two triangles (alternating diagonals). A
//! grid-clipped mesh is simple, robust and uniform, which is all ARAP needs.
//! * **Mesh.** The layer's coverage (alpha above a threshold) becomes a signed distance field,
//! offset by `expansion` to grow or shrink the region. A square grid at the density's spacing,
//! split into triangles, is fitted to that outline (2D isosurface stuffing): vertices near the
//! outline snap onto it and the triangles it crosses are cut along it, so the mesh edge follows
//! the shape, and separate shapes get separate meshes. Inside, the mesh stays a uniform grid,
//! which is all ARAP needs.
//! * **Deformation.** The per-triangle ARAP energy of Liu et al. 2008 (the triangle form of
//! Sorkine & Alexa 2007), `E = Σ_t Σ_(i,j)∈t w_ij |(v_i − v_j) − R_t (x_i − x_j)|²` with
//! cotangent weights, minimized by alternating a local step (best rotation per triangle from
@@ -18,6 +20,8 @@
//! * **Rendering.** Triangles are rasterized with [`crate::warp::warp_triangles`] (tile-parallel,
//! premultiplied, bilinear/bicubic), back to front by the depth of their nearest pin.
use std::collections::{HashMap, HashSet};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
@@ -125,120 +129,619 @@ pub struct PuppetMesh {
/// Coverage threshold: alpha above this counts as part of the puppet.
const ALPHA_MIN: f32 = 0.05;
/// The outline field's longest side, in blocks: how finely the mesh edge follows the outline.
const FIELD_BLOCKS: f64 = 1024.0;
/// The puppet's region as a signed distance field over a block grid: negative inside the
/// opaque area grown (or shrunk) by the expansion, positive outside, in px.
struct Field {
x0: f64,
y0: f64,
/// Block size (px).
g: f64,
bw: usize,
bh: usize,
/// Signed distance from each block centre to the opaque outline (px, negative inside).
sd: Vec<f32>,
expansion: f64,
}
impl Field {
fn new(src: &Surface, bounds: Rect, expansion: f64) -> Field {
let long = f64::from(bounds.width().max(bounds.height()));
let grow = expansion.max(0.0).ceil();
let g = ((long + 2.0 * grow + 8.0) / FIELD_BLOCKS).ceil().max(1.0) as i32;
let area = bounds.inflate(grow as i32 + 2 * g);
let bw = ((area.width() as i32 + g - 1) / g).max(1) as usize;
let bh = ((area.height() as i32 + g - 1) / g).max(1) as usize;
let mut cov = vec![false; bw * bh];
let fmt = src.format();
let n = fmt.channels();
let block = |bx: usize, by: usize| {
let (x, y) = (area.x0 + bx as i32 * g, area.y0 + by as i32 * g);
Rect::new(x, y, x + g, y + g)
};
if !fmt.alpha {
for (i, c) in cov.iter_mut().enumerate() {
*c = !block(i % bw, i / bw).intersect(&bounds).is_empty();
}
} else {
// One block row per task (rows are independent).
let scan = |by: usize, out: &mut [bool]| {
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
if r.is_empty() || !src.has_tiles_in(r) {
return;
}
let row = src.read_region(r);
let rw = r.width() as usize;
for (i, p) in row.chunks_exact(n).enumerate() {
if p.last().is_some_and(|&a| a > ALPHA_MIN) {
let x = r.x0 + (i % rw) as i32;
if let Some(o) = out.get_mut(((x - area.x0) / g) as usize) {
*o = true;
}
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
#[cfg(target_arch = "wasm32")]
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
let outside = edt(&cov, true, bw, bh);
let inside = edt(&cov, false, bw, bh);
let gf = f64::from(g);
let sd = cov
.iter()
.zip(outside.iter().zip(&inside))
.map(|(&c, (&o, &i))| if c { (-(i.sqrt() - 0.5) * gf) as f32 } else { ((o.sqrt() - 0.5) * gf) as f32 })
.collect();
Field { x0: f64::from(area.x0), y0: f64::from(area.y0), g: gf, bw, bh, sd, expansion }
}
/// The field at a block centre (exact, no interpolation).
fn at(&self, bx: usize, by: usize) -> f64 {
self.sd.get(by * self.bw + bx).map_or(f64::MAX, |&d| f64::from(d) - self.expansion)
}
/// The field at `p` (bilinear between block centres; clamped to the grid, which ends
/// outside the region).
fn value(&self, p: [f64; 2]) -> f64 {
let fx = ((p[0] - self.x0) / self.g - 0.5).clamp(0.0, (self.bw - 1) as f64);
let fy = ((p[1] - self.y0) / self.g - 0.5).clamp(0.0, (self.bh - 1) as f64);
let (ix, iy) = (fx.floor() as usize, fy.floor() as usize);
let (ix1, iy1) = ((ix + 1).min(self.bw - 1), (iy + 1).min(self.bh - 1));
let (tx, ty) = (fx - ix as f64, fy - iy as f64);
let top = self.at(ix, iy) * (1.0 - tx) + self.at(ix1, iy) * tx;
let bottom = self.at(ix, iy1) * (1.0 - tx) + self.at(ix1, iy1) * tx;
top * (1.0 - ty) + bottom * ty
}
/// The outline (where the field crosses zero) as closed loops, traced by marching squares
/// over the block centres. The field's border blocks lie outside, so every loop closes.
fn outline(&self) -> Vec<Vec<[f64; 2]>> {
let (bw, bh) = (self.bw, self.bh);
if bw < 2 || bh < 2 {
return Vec::new();
}
let inside = |x: usize, y: usize| self.at(x, y) < 0.0;
// A crossing is named by its block edge: 2·block for the edge to the right neighbour,
// 2·block + 1 for the edge to the one below.
let id = |x: usize, y: usize, down: bool| 2 * (y * bw + x) + usize::from(down);
let point = |id: usize| {
let b = id / 2;
let (x, y) = (b % bw, b / bw);
let (x2, y2) = if id.is_multiple_of(2) { (x + 1, y) } else { (x, y + 1) };
let (fa, fb) = (self.at(x, y), self.at(x2, y2));
let t = if (fa - fb).abs() > 1e-12 { (fa / (fa - fb)).clamp(0.0, 1.0) } else { 0.5 };
let (dx, dy) = ((x2 - x) as f64, (y2 - y) as f64);
[self.x0 + (x as f64 + 0.5 + dx * t) * self.g, self.y0 + (y as f64 + 0.5 + dy * t) * self.g]
};
let mut links: HashMap<usize, [usize; 2]> = HashMap::new();
let mut link = |a: usize, b: usize| {
for (p, q) in [(a, b), (b, a)] {
let e = links.entry(p).or_insert([usize::MAX; 2]);
if e[0] == usize::MAX {
e[0] = q;
} else {
e[1] = q;
}
}
};
for y in 0..bh - 1 {
for x in 0..bw - 1 {
let c = [inside(x, y), inside(x + 1, y), inside(x + 1, y + 1), inside(x, y + 1)];
if c.iter().all(|&v| v == c[0]) {
continue;
}
// Top, right, bottom, left.
let edges = [id(x, y, false), id(x + 1, y, true), id(x, y + 1, false), id(x, y, true)];
let cross = [c[0] != c[1], c[1] != c[2], c[3] != c[2], c[0] != c[3]];
let ks: Vec<usize> = (0..4).filter(|&k| cross[k]).collect();
if let [a, b] = ks[..] {
link(edges[a], edges[b]);
} else if ks.len() == 4 {
// A saddle: the centre decides which diagonal pair is joined.
let centre = self.at(x, y) + self.at(x + 1, y) + self.at(x + 1, y + 1) + self.at(x, y + 1) < 0.0;
if c[0] == centre {
link(edges[0], edges[1]);
link(edges[2], edges[3]);
} else {
link(edges[3], edges[0]);
link(edges[1], edges[2]);
}
}
}
}
let mut keys: Vec<usize> = links.keys().copied().collect();
keys.sort_unstable();
let mut seen: HashSet<usize> = HashSet::with_capacity(keys.len());
let mut loops = Vec::new();
for &start in &keys {
if seen.contains(&start) {
continue;
}
let mut lp = Vec::new();
let (mut prev, mut cur) = (usize::MAX, start);
loop {
seen.insert(cur);
lp.push(point(cur));
let Some(&[a, b]) = links.get(&cur) else { break };
let next = if a != prev { a } else { b };
if next == usize::MAX || seen.contains(&next) {
break;
}
(prev, cur) = (cur, next);
}
if lp.len() >= 3 {
loops.push(lp);
}
}
loops
}
}
/// Squared Euclidean distance (in blocks) from every block to the nearest block whose `mask`
/// equals `site` (Felzenszwalb & Huttenlocher's separable lower-envelope transform). About
/// 1e20 when there is none.
fn edt(mask: &[bool], site: bool, w: usize, h: usize) -> Vec<f64> {
const FAR: f64 = 1e20;
let mut d: Vec<f64> = mask.iter().map(|&m| if m == site { 0.0 } else { FAR }).collect();
let mut f = vec![0.0; w.max(h)];
let mut out = vec![0.0; w.max(h)];
let mut v = vec![0usize; w.max(h)];
let mut z = vec![0.0; w.max(h) + 1];
for x in 0..w {
for (y, fy) in f[..h].iter_mut().enumerate() {
*fy = d[y * w + x];
}
edt_1d(&f[..h], &mut out[..h], &mut v, &mut z);
for (y, o) in out[..h].iter().enumerate() {
d[y * w + x] = *o;
}
}
for row in d.chunks_mut(w) {
f[..w].copy_from_slice(row);
edt_1d(&f[..w], row, &mut v, &mut z);
}
d
}
/// One-dimensional squared distance transform of the sampled function `f`.
fn edt_1d(f: &[f64], out: &mut [f64], v: &mut [usize], z: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let mut k = 0usize;
v[0] = 0;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let meet = |q: usize, p: usize| {
let (qf, pf) = (q as f64, p as f64);
((f[q] + qf * qf) - (f[p] + pf * pf)) / (2.0 * (qf - pf))
};
for q in 1..n {
let mut s = meet(q, v[k]);
while k > 0 && s <= z[k] {
k -= 1;
s = meet(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, o) in out.iter_mut().enumerate() {
let qf = q as f64;
while z[k + 1] < qf {
k += 1;
}
let p = v[k];
*o = (qf - p as f64).powi(2) + f[p];
}
}
/// Distance from `p` to the segment `a`–`b`.
fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
dist2(p, [a[0] + dx * t, a[1] + dy * t]).sqrt()
}
/// Simplifies a closed loop (Douglas–Peucker, within `tol` px), which keeps its corners.
fn simplify(lp: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
let n = lp.len();
if n < 4 {
return lp.to_vec();
}
let far = (1..n).max_by(|&a, &b| dist2(lp[0], lp[a]).total_cmp(&dist2(lp[0], lp[b]))).unwrap_or(n / 2);
let mut keep = vec![false; n];
keep[0] = true;
keep[far] = true;
// Spans (a, b) of the loop; b == n stands for point 0.
let mut stack = vec![(0, far), (far, n)];
while let Some((a, b)) = stack.pop() {
let (pa, pb) = (lp[a], lp[b % n]);
let mut best = (0.0, 0);
for (i, &p) in lp.iter().enumerate().take(b).skip(a + 1) {
let d = seg_dist(p, pa, pb);
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
let out: Vec<[f64; 2]> = (0..n).filter(|&i| keep[i]).map(|i| lp[i]).collect();
if out.len() >= 3 {
return out;
}
// A speck thinner than the tolerance: keep a few of its points.
(0..n).step_by((n / 4).max(1)).map(|i| lp[i]).collect()
}
/// Splits the sides of a closed loop longer than `step` into equal parts.
fn densify(lp: &[[f64; 2]], step: f64) -> Vec<[f64; 2]> {
let mut out = Vec::with_capacity(lp.len());
for (k, &a) in lp.iter().enumerate() {
let b = lp[(k + 1) % lp.len()];
let parts = (dist2(a, b).sqrt() / step).ceil().clamp(1.0, 1e4) as usize;
for i in 0..parts {
out.push(lerp(a, b, i as f64 / parts as f64));
}
}
out
}
fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
}
/// Positive when `d` lies inside the circumcircle of the positively oriented `a`, `b`, `c`.
fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 {
let (ax, ay) = (a[0] - d[0], a[1] - d[1]);
let (bx, by) = (b[0] - d[0], b[1] - d[1]);
let (cx, cy) = (c[0] - d[0], c[1] - d[1]);
(ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay)
}
const NONE: usize = usize::MAX;
/// A triangle of the Delaunay triangulation: vertices (positively oriented) and the neighbour
/// across the edge opposite each vertex.
#[derive(Clone, Copy)]
struct DTri {
v: [usize; 3],
n: [usize; 3],
alive: bool,
}
/// Incremental Delaunay triangulation (Bowyer–Watson), started from a triangle around the area.
/// Vertices 0..3 are that triangle's.
struct Delaunay {
pts: Vec<[f64; 2]>,
tris: Vec<DTri>,
stamp: Vec<u32>,
pass: u32,
last: usize,
}
impl Delaunay {
fn new(lo: [f64; 2], hi: [f64; 2]) -> Delaunay {
let (cx, cy) = (0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1]));
let d = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1.0) * 4.0;
let mut pts = vec![[cx - 2.0 * d, cy - d], [cx + 2.0 * d, cy - d], [cx, cy + 2.0 * d]];
if orient(pts[0], pts[1], pts[2]) < 0.0 {
pts.swap(1, 2);
}
Delaunay { pts, tris: vec![DTri { v: [0, 1, 2], n: [NONE; 3], alive: true }], stamp: vec![0], pass: 0, last: 0 }
}
/// The triangle holding `p`: a visibility walk from the last one made.
fn locate(&self, p: [f64; 2]) -> Option<usize> {
let mut t = if self.tris.get(self.last).is_some_and(|t| t.alive) { self.last } else { self.tris.iter().position(|t| t.alive)? };
for _ in 0..self.tris.len() + 3 {
let tri = self.tris.get(t)?;
let step = (0..3).find(|&k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) < 0.0);
match step {
None => return Some(t),
Some(k) if tri.n[k] != NONE => t = tri.n[k],
Some(_) => return None,
}
}
self.tris.iter().position(|tri| tri.alive && (0..3).all(|k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) >= 0.0))
}
/// Inserts `p`; returns its vertex index (an existing vertex's when `p` coincides with it).
fn insert(&mut self, p: [f64; 2]) -> Option<usize> {
let t0 = self.locate(p)?;
if let Some(&v) = self.tris[t0].v.iter().find(|&&v| dist2(self.pts[v], p) < 1e-12) {
return Some(v);
}
self.pass = self.pass.wrapping_add(1);
let pass = self.pass;
// The cavity: every triangle whose circumcircle holds `p`, grown from `t0`.
let mut bad = vec![t0];
self.stamp[t0] = pass;
let mut i = 0;
while let Some(&t) = bad.get(i) {
i += 1;
for k in 0..3 {
let nb = self.tris[t].n[k];
if nb == NONE || self.stamp[nb] == pass {
continue;
}
let [a, b, c] = self.tris[nb].v.map(|v| self.pts[v]);
if in_circle(a, b, c, p) > 0.0 {
self.stamp[nb] = pass;
bad.push(nb);
}
}
}
let mut rim: Vec<(usize, usize, usize)> = Vec::new();
for &t in &bad {
let tri = self.tris[t];
for k in 0..3 {
let nb = tri.n[k];
if nb == NONE || self.stamp[nb] != pass {
rim.push((tri.v[(k + 1) % 3], tri.v[(k + 2) % 3], nb));
}
}
self.tris[t].alive = false;
}
let pi = self.pts.len();
self.pts.push(p);
let base = self.tris.len();
for (i, &(a, b, o)) in rim.iter().enumerate() {
self.tris.push(DTri { v: [a, b, pi], n: [NONE, NONE, o], alive: true });
self.stamp.push(0);
if let Some(ot) = self.tris.get_mut(o) {
for k in 0..3 {
let (x, y) = (ot.v[(k + 1) % 3], ot.v[(k + 2) % 3]);
if (x == b && y == a) || (x == a && y == b) {
ot.n[k] = base + i;
}
}
}
}
for (i, &(a, b, _)) in rim.iter().enumerate() {
let across_b = rim.iter().position(|r| r.0 == b).map_or(NONE, |j| base + j);
let across_a = rim.iter().position(|r| r.1 == a).map_or(NONE, |j| base + j);
self.tris[base + i].n[0] = across_b;
self.tris[base + i].n[1] = across_a;
}
self.last = base;
Some(pi)
}
fn edges(&self) -> HashSet<(usize, usize)> {
let mut e = HashSet::new();
for t in self.tris.iter().filter(|t| t.alive) {
for k in 0..3 {
let (a, b) = (t.v[k], t.v[(k + 1) % 3]);
e.insert((a.min(b), a.max(b)));
}
}
e
}
}
/// Even-odd point-in-polygon test against closed outline segments, bucketed by row.
struct Inside<'a> {
pts: &'a [[f64; 2]],
segs: &'a [(usize, usize)],
y0: f64,
row: f64,
rows: Vec<Vec<usize>>,
}
impl<'a> Inside<'a> {
fn new(pts: &'a [[f64; 2]], segs: &'a [(usize, usize)], row: f64) -> Self {
let y0 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MAX, f64::min);
let y1 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MIN, f64::max);
let n = if y1 >= y0 { ((y1 - y0) / row).floor().clamp(0.0, 1e5) as usize + 1 } else { 0 };
let mut rows = vec![Vec::new(); n];
for (i, &(a, b)) in segs.iter().enumerate() {
let (lo, hi) = (pts[a][1].min(pts[b][1]), pts[a][1].max(pts[b][1]));
let (r0, r1) = (((lo - y0) / row).floor() as usize, ((hi - y0) / row).floor() as usize);
for r in rows.iter_mut().take(r1 + 1).skip(r0) {
r.push(i);
}
}
Inside { pts, segs, y0, row, rows }
}
fn contains(&self, p: [f64; 2]) -> bool {
if p[1] < self.y0 {
return false;
}
let Some(row) = self.rows.get(((p[1] - self.y0) / self.row).floor() as usize) else { return false };
let mut odd = false;
for &i in row {
let (a, b) = (self.pts[self.segs[i].0], self.pts[self.segs[i].1]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
odd = !odd;
}
}
odd
}
}
/// Most outline points the mesh takes; a busier outline is simplified more coarsely.
const MAX_OUTLINE_POINTS: usize = 20_000;
/// Builds the mesh over the covered part of `src` inside `bounds`.
///
/// The outline of the opaque region grown by `expansion` is traced, simplified (corners kept)
/// and split into sides no longer than the density's spacing; a triangular lattice at that
/// spacing fills the inside, at least half a step from the outline. The mesh is the
/// Delaunay triangulation of those points with the outline's sides recovered (split until
/// each is an edge), minus the triangles outside the outline.
pub fn build_mesh(src: &Surface, bounds: Rect, density: PuppetDensity, expansion: f64) -> PuppetMesh {
if bounds.is_empty() {
return PuppetMesh::default();
}
let long = f64::from(bounds.width().max(bounds.height()));
let e = if expansion.is_finite() { expansion.clamp(-200.0, 200.0) } else { 0.0 };
// The density counts steps across the region the mesh covers (grown by the expansion).
let long = f64::from(bounds.width().max(bounds.height())) + 2.0 * e.max(0.0);
let spacing = (long / density.cells()).max(2.0);
let e = expansion.clamp(-200.0, 200.0);
// Coverage on a block grid (block = a quarter cell or less).
let g = ((spacing / 4.0).floor() as i32).max(1);
let pad = e.max(0.0).ceil() as i32 + g;
let area = bounds.inflate(pad);
let bw = (area.width() as i32 + g - 1) / g;
let bh = (area.height() as i32 + g - 1) / g;
let (bw, bh) = (bw as usize, bh as usize);
let mut cov = vec![false; bw * bh];
let fmt = src.format();
let n = fmt.channels();
if !fmt.alpha {
for by in 0..bh {
for bx in 0..bw {
let r = Rect::new(area.x0 + bx as i32 * g, area.y0 + by as i32 * g, area.x0 + (bx as i32 + 1) * g, area.y0 + (by as i32 + 1) * g);
cov[by * bw + bx] = !r.intersect(&bounds).is_empty();
}
}
} else {
// One block row per task (rows are independent).
let scan = |by: usize, out: &mut [bool]| {
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
if r.is_empty() || !src.has_tiles_in(r) {
return;
}
let row = src.read_region(r);
let rw = r.width() as usize;
for (i, p) in row.chunks_exact(n).enumerate() {
if p[n - 1] > ALPHA_MIN {
let x = r.x0 + (i % rw) as i32;
out[((x - area.x0) / g) as usize] = true;
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
#[cfg(target_arch = "wasm32")]
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
let field = Field::new(src, bounds, e);
let loops = field.outline();
let empty = PuppetMesh { spacing, ..Default::default() };
if loops.is_empty() {
return empty;
}
// Grow / shrink by the expansion (round stamp in block units).
let eb = (e.abs() / f64::from(g)).round() as i32;
if eb > 0 {
let src_cov = cov.clone();
let grow = e > 0.0;
for by in 0..bh as i32 {
for bx in 0..bw as i32 {
let mut hit = !grow;
'stamp: for dy in -eb..=eb {
for dx in -eb..=eb {
if dx * dx + dy * dy > eb * eb {
continue;
}
let (x, y) = (bx + dx, by + dy);
let v = x >= 0 && y >= 0 && x < bw as i32 && y < bh as i32 && src_cov[y as usize * bw + x as usize];
if grow && v {
hit = true;
break 'stamp;
}
if !grow && !v {
hit = false;
break 'stamp;
}
}
}
cov[by as usize * bw + bx as usize] = hit;
let mut tol = (0.5 * field.g).max(0.5);
let rings = loop {
let rings: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| densify(&simplify(l, tol), spacing)).collect();
if rings.iter().map(Vec::len).sum::<usize>() <= MAX_OUTLINE_POINTS || tol > spacing {
break rings;
}
tol *= 2.0;
};
let (lo, hi) = ([field.x0, field.y0], [field.x0 + field.bw as f64 * field.g, field.y0 + field.bh as f64 * field.g]);
let mut dl = Delaunay::new(lo, hi);
let mut segs: Vec<(usize, usize)> = Vec::new();
for ring in &rings {
let idx: Vec<usize> = ring.iter().filter_map(|&p| dl.insert(p)).collect();
for (k, &a) in idx.iter().enumerate() {
let b = idx[(k + 1) % idx.len()];
if a != b {
segs.push((a, b));
}
}
}
// Grid cells touching covered blocks.
let ox = f64::from(area.x0);
let oy = f64::from(area.y0);
let cw = (f64::from(bw as i32 * g) / spacing).ceil() as usize;
let ch = (f64::from(bh as i32 * g) / spacing).ceil() as usize;
let mut index = vec![usize::MAX; (cw + 1) * (ch + 1)];
let mut mesh = PuppetMesh { spacing, ..Default::default() };
for cj in 0..ch {
for ci in 0..cw {
let bx0 = ((ci as f64 * spacing) / f64::from(g)).floor() as usize;
let by0 = ((cj as f64 * spacing) / f64::from(g)).floor() as usize;
let bx1 = (((ci + 1) as f64 * spacing) / f64::from(g)).ceil().min(bw as f64) as usize;
let by1 = (((cj + 1) as f64 * spacing) / f64::from(g)).ceil().min(bh as f64) as usize;
let any = (by0..by1).any(|y| (bx0..bx1).any(|x| cov[y * bw + x]));
if !any {
// The inside: a triangular lattice, kept half a step clear of the outline.
let rise = spacing * 3f64.sqrt() / 2.0;
let rows = ((hi[1] - lo[1]) / rise).ceil() as usize;
let cols = ((hi[0] - lo[0]) / spacing).ceil() as usize;
for j in 0..=rows {
for i in 0..=cols {
let p = [lo[0] + (i as f64 + 0.5 * (j % 2) as f64) * spacing, lo[1] + j as f64 * rise];
if field.value(p) < -0.5 * spacing {
dl.insert(p);
}
}
}
// Recover the outline's sides: split a side the triangulation lacks at its midpoint.
let mut budget = segs.len() * 2;
for _ in 0..8 {
let edges = dl.edges();
let mut next = Vec::with_capacity(segs.len());
let mut split = false;
for &(a, b) in &segs {
if budget == 0 || edges.contains(&(a.min(b), a.max(b))) {
next.push((a, b));
continue;
}
let mut v = |i: usize, j: usize| {
let k = j * (cw + 1) + i;
if index[k] == usize::MAX {
index[k] = mesh.verts.len();
mesh.verts.push([ox + i as f64 * spacing, oy + j as f64 * spacing]);
match dl.insert(lerp(dl.pts[a], dl.pts[b], 0.5)) {
Some(c) if c != a && c != b => {
next.push((a, c));
next.push((c, b));
budget -= 1;
split = true;
}
index[k]
};
let (a, b, c, d) = (v(ci, cj), v(ci + 1, cj), v(ci + 1, cj + 1), v(ci, cj + 1));
if (ci + cj) % 2 == 0 {
mesh.tris.push([a, b, c]);
mesh.tris.push([a, c, d]);
} else {
mesh.tris.push([a, b, d]);
mesh.tris.push([b, c, d]);
_ => next.push((a, b)),
}
}
segs = next;
if !split {
break;
}
}
let inside = Inside::new(&dl.pts, &segs, spacing);
let mut tris: Vec<[usize; 3]> = Vec::new();
for t in dl.tris.iter().filter(|t| t.alive && t.v.iter().all(|&v| v >= 3)) {
let [a, b, c] = t.v.map(|v| dl.pts[v]);
if orient(a, b, c) > 1e-9 && inside.contains([(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0]) {
tris.push(t.v);
}
}
if tris.is_empty() {
return empty;
}
// Keep the vertices in use, numbered for a narrow band in the solver's matrix.
let pos = &dl.pts;
let order = band_order(pos, &tris);
let mut remap = vec![usize::MAX; pos.len()];
for (new, &old) in order.iter().enumerate() {
remap[old] = new;
}
PuppetMesh { verts: order.iter().map(|&i| pos[i]).collect(), tris: tris.iter().map(|t| t.map(|i| remap[i])).collect(), spacing }
}
/// The vertices `tris` use, in reverse Cuthill–McKee order (breadth first from the topmost
/// vertex of each piece, lower degrees first), which keeps the solver's banded factor small.
fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); pos.len()];
for t in tris {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
adj[a].push(b);
adj[b].push(a);
}
}
for a in &mut adj {
a.sort_unstable();
a.dedup();
}
let mut starts: Vec<usize> = (0..pos.len()).filter(|&i| !adj[i].is_empty()).collect();
starts.sort_by(|&a, &b| pos[a][1].total_cmp(&pos[b][1]).then(pos[a][0].total_cmp(&pos[b][0])));
let mut seen = vec![false; pos.len()];
let mut order = Vec::with_capacity(starts.len());
for s in starts {
if seen[s] {
continue;
}
seen[s] = true;
let first = order.len();
order.push(s);
let mut i = first;
while let Some(&v) = order.get(i) {
i += 1;
let mut next: Vec<usize> = adj[v].iter().copied().filter(|&w| !seen[w]).collect();
next.sort_by_key(|&w| adj[w].len());
for w in next {
seen[w] = true;
order.push(w);
}
}
}
mesh
order.reverse();
order
}
/// A compressed sparse row matrix (symmetric, used by CG).
@@ -330,9 +833,9 @@ impl Csr {
}
}
/// Banded Cholesky factor `A = L Lᵀ` (grid meshes number their vertices row by row, so the
/// system's bandwidth is about two mesh rows): factored once per pin set, then each global step
/// is two triangular solves.
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
struct BandChol {
n: usize,
p: usize,
@@ -833,6 +1336,43 @@ mod tests {
assert!(more.tris.len() > m.tris.len() && m.tris.len() > fewer.tris.len());
}
fn in_mesh(m: &PuppetMesh, p: [f64; 2]) -> bool {
m.tris.iter().any(|t| {
let [a, b, c] = t.map(|i| m.verts[i]);
let (d0, d1, d2) = (orient(a, b, p), orient(b, c, p), orient(c, a, p));
(d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
})
}
#[test]
fn mesh_follows_the_outline() {
// #3105: the mesh was every grid cell touching the shape, a staircase up to a cell
// outside it. It now follows the outline grown by the expansion.
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
for y in 40..160 {
for x in 40..160 {
if (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0) <= 60.0 {
s.write_pixel(x, y, &[0.3, 0.6, 0.9, 1.0]);
}
}
}
let m = build_mesh(&s, s.content_bounds(), PuppetDensity::Normal, 2.0);
let r = |v: &[f64; 2]| (v[0] - 100.0).hypot(v[1] - 100.0);
assert!(m.verts.iter().all(|v| r(v) <= 63.0), "a vertex outside the grown disc");
assert!(m.verts.iter().filter(|v| r(v) > 61.0).count() >= 40, "the outline carries vertices");
let area: f64 = m.tris.iter().map(|t| orient(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]).abs() / 2.0).sum();
let disc = std::f64::consts::PI * 62.0 * 62.0;
assert!((area / disc - 1.0).abs() < 0.03, "{area} vs {disc}");
// Sharp corners and a concave corner: every opaque pixel is still inside the mesh, even
// without an expansion, and the empty corner of the L is not.
let l = blob(SampleType::U8);
let m = build_mesh(&l, l.content_bounds(), PuppetDensity::Fewer, 0.0);
for (x, y) in (20..36).flat_map(|x| (10..70).map(move |y| (x, y))).chain((36..70).flat_map(|x| (56..70).map(move |y| (x, y)))) {
assert!(in_mesh(&m, [f64::from(x) + 0.5, f64::from(y) + 0.5]), "({x}, {y}) is outside the mesh");
}
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
}
#[test]
fn no_pins_or_unmoved_pins_are_identity() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+26 -4
View File
@@ -23,6 +23,12 @@ const INF_DIST: u32 = u32::MAX;
/// Residual capacities at or below this count as saturated (guards against float round-off
/// leaving tiny positive residuals that would cause long chains of negligible augmentations).
const EPS: f32 = 1e-5;
/// How many steps the max-flow loops take between asks whether to stop.
const CANCEL_EVERY: u32 = 1024;
fn never() -> bool {
false
}
/// A directed graph with source/sink terminal capacities.
#[derive(Clone, Debug, Default)]
@@ -110,24 +116,35 @@ impl Graph {
/// Computes the maximum flow (= minimum cut capacity).
pub fn maxflow(&mut self) -> f64 {
self.maxflow_with(&never).unwrap_or(self.flow)
}
/// [`Graph::maxflow`] that stops when `cancel` returns true (asked every few thousand steps):
/// `None` then, and the cut is meaningless. Not cancelled, it does exactly what
/// [`Graph::maxflow`] does.
pub fn maxflow_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
if self.node_count() >= 4096 {
let mut source = false;
let mut sink = false;
for &capacity in &self.tr_cap {
if !capacity.is_finite() {
return self.maxflow_bk();
return self.bk(cancel);
}
source |= capacity > EPS;
sink |= capacity < -EPS;
}
if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) {
return self.maxflow_push_relabel();
return self.push_relabel(cancel);
}
}
self.maxflow_bk()
self.bk(cancel)
}
pub(super) fn maxflow_bk(&mut self) -> f64 {
self.bk(&never).unwrap_or(self.flow)
}
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
let n = self.node_count();
let mut queue: VecDeque<u32> = VecDeque::new();
let mut orphans: VecDeque<u32> = VecDeque::new();
@@ -145,7 +162,12 @@ impl Graph {
}
}
let mut time = 0u32;
let mut steps = 0u32;
while let Some(i) = queue.pop_front() {
steps = steps.wrapping_add(1);
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
return None;
}
let i = i as usize;
self.active[i] = false;
if self.parent[i] == NONE {
@@ -197,7 +219,7 @@ impl Graph {
self.adopt(o as usize, time, &mut queue, &mut orphans);
}
}
self.flow
Some(self.flow)
}
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
@@ -5,14 +5,20 @@
//! The existing SoA residual graph is reused. Terminal source arcs are saturated
//! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local
//! f32 capacity updates do not accumulate excess-rounding drift.
use super::{EPS, Graph, NONE, TERMINAL};
use super::{CANCEL_EVERY, EPS, Graph, NONE, TERMINAL};
use std::collections::VecDeque;
impl Graph {
#[cfg(test)]
pub(super) fn maxflow_push_relabel(&mut self) -> f64 {
self.push_relabel(&super::never).unwrap_or(self.flow)
}
/// Push/relabel; `None` when `cancel` stopped it.
pub(super) fn push_relabel(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
let n = self.node_count();
let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else {
return self.maxflow_bk();
return self.bk(cancel);
};
let mut back = vec![0.0f32; n];
let mut excess = vec![0.0f64; n];
@@ -34,7 +40,12 @@ impl Graph {
}
let mut work = 0usize;
let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n);
let mut steps = 0u32;
while let Some(i) = queue.pop_front() {
steps = steps.wrapping_add(1);
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
return None;
}
self.active[i] = false;
while excess[i] > f64::from(EPS) {
if self.dist[i] == 1 && self.tr_cap[i] < -EPS {
@@ -127,7 +138,7 @@ impl Graph {
a = self.next[ai];
}
}
self.flow
Some(self.flow)
}
fn global_relabel(&mut self, source_height: u32, back: &[f32]) {
+29 -5
View File
@@ -242,14 +242,38 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[
grid_cut_weighted::<true>(img, cost_fg, cost_bg, fixed, weight)
}
/// [`grid_cut_with`] that stops when `cancel` returns true (asked every few thousand max-flow
/// steps): `None` then. Not cancelled, the same cut.
pub fn grid_cut_cancellable(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
}
mod edge_starts;
// The const parameter also runs the previous full scan in before/after workflow tests.
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
}
fn grid_cut_impl<const OPTIMIZED: bool>(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w as i32, img.h as i32);
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() };
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) {
return Vec::new();
return Some(Vec::new());
}
let mut free = Vec::new();
let mut idx = vec![u32::MAX; n];
@@ -262,7 +286,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
}
let mut out: Vec<bool> = fixed.iter().map(|f| *f == HARD_FG).collect();
if count == 0 {
return out;
return Some(out);
}
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
for i in 0..n {
@@ -307,7 +331,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
}
}
if OPTIMIZED {
g.maxflow();
g.maxflow_with(cancel)?;
} else {
g.maxflow_bk();
}
@@ -316,7 +340,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
out[i] = g.in_source(idx[i] as usize);
}
}
out
Some(out)
}
fn add_fixed_link(g: &mut Graph, node: usize, fixed: u8, wt: f32) {
+29
View File
@@ -35,6 +35,35 @@ fn maxflow_clrs_network() {
assert_eq!(g.residual_out_of_source_side(), 0.0);
}
#[test]
fn maxflow_can_be_cancelled_and_otherwise_cuts_the_same() {
// A 120 × 100 grid with terminal links both ways (push/relabel) and a small one (BK).
for (w, h) in [(120usize, 100usize), (20, 15)] {
let mut g = Graph::with_capacity(w * h, w * h * 2);
let mut z = 7u64;
let mut next = || {
z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(z >> 40) as f32 / (1u64 << 24) as f32
};
for i in 0..w * h {
g.add_tweights(i, next() * 2.0, next() * 2.0);
if i % w + 1 < w {
g.add_edge(i, i + 1, next(), next());
}
if i + w < w * h {
g.add_edge(i, i + w, next(), next());
}
}
let (mut plain, mut asked) = (g.clone(), g.clone());
let flow = plain.maxflow();
assert_eq!(asked.maxflow_with(&|| false), Some(flow));
assert!((0..w * h).all(|i| plain.in_source(i) == asked.in_source(i)));
if w * h > 4096 {
assert_eq!(g.maxflow_with(&|| true), None);
}
}
}
/// Reference max flow (Edmonds–Karp on a dense matrix; node 0 = s, n−1 = t).
fn edmonds_karp(cap: &mut [Vec<f64>]) -> f64 {
let n = cap.len();
+167 -29
View File
@@ -766,47 +766,84 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
}
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased).
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of
/// rows are filled in parallel, and each sub-row tests only the edges that
/// cross it (an active edge list over the edges sorted by their top).
pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
if points.len() < 3 || w == 0 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
let sample_y = |py: usize, s: usize| area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
// A sub-row at y crosses the edge a→b iff top <= y < bottom; horizontal and NaN edges
// never cross one.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
const BAND: usize = 64;
par_rows(&mut m, w, BAND, |bi, rows| {
let py0 = bi * BAND;
let (first, last) = (sample_y(py0, 0), sample_y(py0 + rows.len() / w - 1, sub - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let mut xs: Vec<f32> = Vec::new();
for (r, row) in rows.chunks_exact_mut(w).enumerate() {
for s in 0..sub {
// Sub-row ys never decrease, so an edge whose bottom is passed stays out.
let y = sample_y(py0 + r, s);
while let Some(e) = pending.next_if(|e| e.top <= y) {
active.push(e);
}
active.retain(|e| e.bottom > y);
if active.is_empty() {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
xs.clear();
xs.extend(active.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for (cx, v) in row.iter_mut().enumerate().take(c1).skip(c0) {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
*v += cov.max(0.0) / sub as f32;
}
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
for v in rows {
*v = v.min(1.0);
}
});
m
}
@@ -1161,6 +1198,107 @@ mod tests {
assert!(polygon(&[(0.0, 0.0), (1.0, 1.0)], a, true).iter().all(|v| *v == 0.0));
}
/// The previous polygon fill (every edge tested on every sub-row), kept as the oracle.
fn old_polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
#[test]
fn polygon_matches_the_every_edge_fill_bit_for_bit() {
let mut seed = 0x2545_f491_u32;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as f32 / u32::MAX as f32
};
// Taller than one 64-row band, offset, and a 1-pixel-wide area.
let areas = [Rect::new(0, 0, 90, 150), Rect::new(-13, 7, 60, 211), Rect::new(5, -40, 6, 30)];
let mut cases: Vec<Vec<(f32, f32)>> = vec![
// Self-intersecting star, horizontal edges, repeated points, collinear runs.
(0..11)
.map(|i| {
let t = i as f32 * 4.0 * std::f32::consts::PI / 11.0;
(40.0 + 35.0 * t.cos(), 80.0 + 70.0 * t.sin())
})
.collect(),
vec![(10.0, 10.0), (50.0, 10.0), (50.0, 10.0), (50.0, 60.0), (30.0, 60.0), (30.0, 30.0), (10.0, 30.0)],
vec![(0.0, 0.0), (20.0, 0.0), (40.0, 0.0), (40.0, 0.0), (0.0, 0.0)],
// Vertices on the sub-row centres and the pixel edges.
vec![(3.125, 2.375), (70.5, 2.375), (70.875, 140.625), (3.0, 140.0), (35.0, 64.0), (35.0, 64.125)],
// Entirely outside, and straddling every side.
vec![(-500.0, -500.0), (-400.0, -500.0), (-450.0, -300.0)],
vec![(-80.0, -60.0), (300.0, 20.0), (60.0, 400.0)],
// Non-finite coordinates and a negative zero.
vec![(10.0, 10.0), (f32::NAN, 40.0), (60.0, 70.0), (20.0, f32::INFINITY), (-0.0, -0.0), (0.0, 0.0)],
];
for n in [3, 7, 40, 400] {
for spread in [1.0, 2.0] {
cases.push((0..n).map(|_| (rnd() * 100.0 * spread - 50.0 * (spread - 1.0), rnd() * 220.0 * spread - 60.0 * spread)).collect());
// Lasso-like: a random walk snapped to quarter pixels.
let (mut x, mut y) = (45.0f32, 75.0f32);
cases.push(
(0..n)
.map(|_| {
x = ((x + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
y = ((y + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
(x, y)
})
.collect(),
);
}
}
for pts in &cases {
for &area in &areas {
for aa in [true, false] {
let (new, old) = (polygon(pts, area, aa), old_polygon(pts, area, aa));
assert_eq!(new.len(), old.len());
let diff = new.iter().zip(&old).position(|(a, b)| a.to_bits() != b.to_bits());
assert!(diff.is_none(), "{pts:?} {area:?} aa={aa}: first difference at {diff:?}");
}
}
}
}
#[test]
fn combine_modes() {
let a = Rect::new(0, 0, 4, 1);
+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]);
}
}
+41 -2
View File
@@ -186,7 +186,7 @@ pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) {
/// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a
/// [`convex_quad`] or the size isn't positive.
pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
if !convex_quad(q) || !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
if !(convex_quad(q) && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
@@ -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();
+86
View File
@@ -114,6 +114,73 @@ impl VpPlane {
});
Lifted { focal: f, center: c, pts }
}
/// Turn of each corner (`sin` of the angle between consecutive edges, positive when the
/// corners run clockwise on screen); `None` when an edge is shorter than a pixel or a corner
/// is not finite.
fn turns(&self) -> Option<[f64; 4]> {
let q = &self.corners;
if q.iter().flatten().any(|v| !v.is_finite() || v.abs() > 1e9) {
return None;
}
let e = |i: usize| [q[(i + 1) % 4][0] - q[i][0], q[(i + 1) % 4][1] - q[i][1]];
let mut out = [0.0; 4];
for (i, t) in out.iter_mut().enumerate() {
let (a, b) = (e(i), e((i + 1) % 4));
let (la, lb) = (a[0].hypot(a[1]), b[0].hypot(b[1]));
if la < 1.0 || lb < 1.0 {
return None;
}
*t = (a[0] * b[1] - a[1] * b[0]) / (la * lb);
}
Some(out)
}
/// How usable the plane is, the way Photoshop colours its grid: [`PlaneCheck::Invalid`] for
/// a quad that isn't convex (self-intersecting, a corner pushed inside, a near-straight or
/// near-zero corner), [`PlaneCheck::Questionable`] for a convex quad whose perspective can't
/// come from a camera centred at `center` (or with a corner sharper than 10°), else
/// [`PlaneCheck::Valid`].
pub fn check(&self, center: [f64; 2]) -> PlaneCheck {
let Some(t) = self.turns() else { return PlaneCheck::Invalid };
let same_sign = t.iter().all(|v| *v > 0.0) || t.iter().all(|v| *v < 0.0);
let min = t.iter().fold(f64::MAX, |m, v| m.min(v.abs()));
if !same_sign || min < 1f64.to_radians().sin() || self.homography().is_none() {
return PlaneCheck::Invalid;
}
if min < 10f64.to_radians().sin() {
return PlaneCheck::Questionable;
}
// Both vanishing points finite but no real focal length fits them.
let q = &self.corners;
let (v1, v2) = (meet(q[0], q[1], q[3], q[2]), meet(q[0], q[3], q[1], q[2]));
let finite = |v: V3| v[2].abs() > 1e-9 * (v[0].abs() + v[1].abs()).max(1.0);
if finite(v1) && finite(v2) && self.focal(center).is_none() {
return PlaneCheck::Questionable;
}
PlaneCheck::Valid
}
/// The same quad with its corners clockwise on screen, starting at the top-left one
/// (the order [`paste`] and [`Scene::tear_off`] expect), however they were clicked.
pub fn normalized(&self) -> VpPlane {
let mut q = self.corners;
if self.turns().is_some_and(|t| t.iter().sum::<f64>() < 0.0) {
q = [q[0], q[3], q[2], q[1]];
}
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1]))).unwrap_or(0);
VpPlane { corners: [q[first], q[(first + 1) % 4], q[(first + 2) % 4], q[(first + 3) % 4]] }
}
}
/// Plane validity ([`VpPlane::check`]): Photoshop draws a valid plane's grid blue, a doubtful
/// one yellow and an invalid one red.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaneCheck {
Valid,
Questionable,
Invalid,
}
impl Lifted {
@@ -363,6 +430,25 @@ mod tests {
assert!((lu / lv - 2.0).abs() < 0.01, "aspect {}", lu / lv);
}
#[test]
fn plane_check_and_corner_order() {
let c = [400.0, 300.0];
assert_eq!(floor_quad(500.0, c).check(c), PlaneCheck::Valid);
let square = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
assert_eq!(square.check(c), PlaneCheck::Valid, "fronto-parallel");
let bowtie = VpPlane { corners: [[10.0, 10.0], [110.0, 110.0], [110.0, 10.0], [10.0, 110.0]] };
assert_eq!(bowtie.check(c), PlaneCheck::Invalid);
let dent = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [40.0, 40.0], [10.0, 110.0]] };
assert_eq!(dent.check(c), PlaneCheck::Invalid, "concave");
let sliver = VpPlane { corners: [[10.0, 10.0], [300.0, 10.0], [300.0, 40.0], [200.0, 40.0]] };
assert_eq!(sliver.check(c), PlaneCheck::Questionable, "a corner sharper than 10°");
let nan = VpPlane { corners: [[f64::NAN, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
assert_eq!(nan.check(c), PlaneCheck::Invalid);
// Clicked counter-clockwise from the bottom-right: clockwise from the top-left.
let ccw = VpPlane { corners: [[110.0, 110.0], [110.0, 10.0], [10.0, 10.0], [10.0, 110.0]] };
assert_eq!(ccw.normalized(), square);
}
#[test]
fn tear_off_is_perpendicular_and_shares_the_edge() {
let c = [400.0, 300.0];
+98 -19
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.
@@ -394,17 +447,43 @@ mod tests {
let out = warp_mesh_surface(&src, r, &|x, y| (x + 10.0, y - 2.0), Interp::Bilinear);
assert_eq!(out.content_bounds(), r.translate(10, -2));
assert_eq!(out.pixel(20, 10), src.pixel(10, 12));
// Arc 50 %: the centre column keeps its pixels, the ends drop.
// Arc 50 % (Photoshop): the bottom corners keep their pixels, the top rises and spreads.
let b = [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)];
let w = Warp::preset(WarpStyle::Arc, 50.0, b);
let arc = warp_mesh_surface(&src, r, &|x, y| w.map(x, y), Interp::Bilinear);
let cx = (r.x0 + r.x1) / 2;
let (p, q) = (arc.pixel(cx, 10), src.pixel(cx, 10));
assert!(p.iter().zip(&q).all(|(a, b)| (a - b).abs() < 0.05), "{p:?} vs {q:?}");
let corner = w.map(f64::from(r.x0), f64::from(r.y1));
assert!((corner.0 - f64::from(r.x0)).abs() < 1e-9 && (corner.1 - f64::from(r.y1)).abs() < 1e-9, "{corner:?}");
let (ex, ey) = w.map(f64::from(r.x0) + 2.5, f64::from(r.y0) + 2.5);
let e = arc.pixel(ex.floor() as i32, ey.floor() as i32);
assert!(e[3] > 0.2, "a point near the top-left corner lands at its mapped position: {e:?}");
assert!(arc.content_bounds().y1 > r.y1 + 2, "ends drop below the original box");
let c = arc.content_bounds();
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]
+15 -2
View File
@@ -41,6 +41,17 @@ pub fn previews(doc: &Document) -> SaveOptions {
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) }
}
/// Encode the name the saved native bundle will report on reopening. Save As updates
/// the session identity only *after* a successful write; without a separate snapshot
/// the manifest serializes the previous name instead.
fn named_for_output(doc: &Document, path: &str) -> Document {
let mut named = doc.clone();
if let Some(name) = path.rsplit(['/', '\\']).next().filter(|name| !name.is_empty()) {
named.name = name.to_owned();
}
named
}
/// Save or export `doc` to `path`, choosing the format from the extension
/// (or `format_override`, an extension such as `"png"`). `writer` makes
/// repeated `.pcraft` saves incremental. Returns warnings about lost data.
@@ -56,9 +67,10 @@ pub fn save(
.or_else(|| path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{}`; pass a format", path.display())))?;
if ext == photocraft_format::EXTENSION {
let named = named_for_output(doc, &path.to_string_lossy());
let mut local = PcraftWriter::new();
let w = writer.unwrap_or(&mut local);
w.save_path(doc, path, &previews(doc))?;
w.save_path(&named, path, &previews(&named))?;
return Ok(Vec::new());
}
let r = photocraft_io::export(doc, &ext, opts)?;
@@ -75,7 +87,8 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt
.or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
if ext == photocraft_format::EXTENSION {
return Ok((photocraft_format::save_to_bytes(doc, &previews(doc))?, Vec::new()));
let named = named_for_output(doc, name);
return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new()));
}
let result = photocraft_io::export(doc, &ext, opts)?;
Ok((result.bytes, result.warnings))
+131 -18
View File
@@ -9,7 +9,7 @@ use photocraft_format::PcraftWriter;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
use crate::workspace::{authorize_engine_command, authorize_engine_step};
use crate::workspace::{ExportSequence, authorize_engine_command, authorize_engine_step};
use crate::{AuthorizedWorkspace, AutomationError, files};
fn untrusted(filesystem: Filesystem) -> Headless {
@@ -96,16 +96,125 @@ 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,
}))
}
/// Place a file into a document as a Smart Object layer: the capability-scoped counterpart
/// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
return self.command_run("file.placeEmbedded", Value::Object(opts));
}
let bytes = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
self.sync_jobs();
// The bytes are already in memory and the path resolved beneath the root capability, so
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?)
}
/// Export each exported layer of a document as its own file beneath the write root: the
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
/// both paths write the same files. The write capability refuses to overwrite a file that
/// already exists, where the ambient command would, and it creates the directory before the
/// export runs, so a refused export can leave an empty directory behind — never a file.
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.layersToFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
}
/// Export each data set of a document as its own flattened file beneath the write root: the
/// capability-scoped `file.export.dataSetsAsFiles` (#3216). As in the command, the whole
/// batch is planned first and colliding names are refused before any file is written; the
/// directory itself is created before that, so a refused batch can leave it empty behind.
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
}
/// Import a data set CSV from the read root: the capability-scoped
/// `file.import.variableDataSets`, which takes an ambient `path` and is therefore refused
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
/// handed to the command's own parser over the text it already has.
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return self.command_run("file.import.variableDataSets", Value::Object(opts));
}
let text = match &self.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
}
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
};
self.sync_jobs();
Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?)
}
/// Save (`.pcraft` or any export format by extension). With no path,
/// saves to the document's own path.
pub fn save(&mut self, index: Option<usize>, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result<Value, AutomationError> {
@@ -133,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();
@@ -145,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
@@ -210,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> {
@@ -282,3 +378,20 @@ impl Headless {
}
}
}
/// Common tail of the two capability-scoped exports: the sequence keeps what it wrote, or drops
/// every file it created, so a half-finished export never looks like a finished one.
fn exported(dir: &str, written: photocraft_engine::Result<Vec<String>>, outputs: &mut ExportSequence) -> Result<Value, AutomationError> {
match written {
Ok(names) => {
outputs.commit();
let files: Vec<String> = names.iter().map(|name| format!("{dir}/{name}")).collect();
let count = files.len();
Ok(json!({"files": files, "count": count}))
}
Err(error) => {
outputs.cleanup()?;
Err(error.into())
}
}
}
+179 -3
View File
@@ -8,7 +8,12 @@
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
//! the counterpart of the refused ambient `file.placeEmbedded`),
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
//! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
@@ -25,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};
@@ -37,8 +42,12 @@ pub const METHODS: &[&str] = &[
"engine.commands",
"session.list",
"doc.open",
"doc.place",
"doc.new",
"doc.save",
"doc.exportLayers",
"doc.exportDataSets",
"doc.importDataSets",
"doc.inspect",
"doc.render",
"doc.select",
@@ -95,11 +104,19 @@ impl Headless {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?;
self.open(&PathBuf::from(path))
}
"doc.place" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.place(index_of(&p), path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
@@ -108,6 +125,21 @@ impl Headless {
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
}
"doc.exportLayers" | "doc.exportDataSets" => {
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("dir");
opts.remove("index");
let opts = Value::Object(opts);
if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) }
}
"doc.importDataSets" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.import_data_sets(index_of(&p), path, &Value::Object(opts))
}
"doc.inspect" => self.inspect(index_of(&p)),
"doc.render" => {
let max = match p.get("maxSide") {
@@ -257,7 +289,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;
@@ -572,6 +604,120 @@ mod tests {
std::fs::remove_dir(root).unwrap();
}
#[test]
fn doc_place_reads_through_the_read_root_and_never_outside_it() {
let base = std::env::temp_dir().join(format!("pc-doc-place-{}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
for dir in [&root, &outside] {
std::fs::create_dir_all(dir).unwrap();
}
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 8 * 6 * 3]).unwrap();
let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("logo.png"), &png).unwrap();
std::fs::write(outside.join("logo.png"), &png).unwrap();
let absolute = outside.join("logo.png").to_string_lossy().into_owned();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let placed = h.handle("doc.place", json!({"path": "logo.png", "scale": 50})).unwrap();
assert!(placed["layer"].as_u64().is_some(), "{placed}");
assert_eq!(placed["scale"].as_f64(), Some(50.0), "the options reach the command: {placed}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "the placed layer is in the document: {inspected}");
// A path outside the read root is refused the way the ambient command is (#2810).
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside the automation root")] {
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err().to_string();
assert!(refused.contains(reason), "{refused}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "nothing was placed: {refused}");
}
// Trusted local keeps the ambient command: that caller may read the path itself.
let mut t = Headless::trusted_local();
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let trusted = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap();
assert!(trusted["layer"].as_u64().is_some(), "{trusted}");
// Release directory capabilities before cleanup on Windows.
drop(t);
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 12, "height": 8, "background": "white", "name": "card"})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Badge"}})).unwrap();
let out = root.join("out");
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": "png", "prefix": "card"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
let mut names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names.len(), 2, "{exported}");
for name in names {
assert!(name.starts_with("card_") && name.ends_with(".png"), "{name}");
let bytes = std::fs::read(out.join(&name)).unwrap();
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().dimensions(), (12, 8), "{name}");
}
// Outside the write root nothing is created, not even the directory.
let escaped = base.join("escaped");
let refused = h.handle("doc.exportLayers", json!({"dir": escaped.to_string_lossy()})).unwrap_err().to_string();
assert!(refused.contains("must be inside the automation root"), "{refused}");
assert!(!escaped.exists());
// The ambient command keeps its refusal for the very same directory.
let ambient =
h.handle("engine.execute", json!({"command": "file.export.layersToFiles", "params": {"dir": out.to_string_lossy()}})).unwrap_err().to_string();
assert!(ambient.contains("ambient filesystem paths"), "{ambient}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_refuse_collisions_and_import_reads_the_root() {
let base = std::env::temp_dir().join(format!("pc-doc-data-sets-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let inspected = h.handle("doc.inspect", json!({})).unwrap();
let layer = inspected["layers"][0]["id"].as_u64().expect("the layer id");
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.unwrap();
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.unwrap();
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
assert_eq!(std::fs::read_dir(&sets).unwrap().count(), 2, "{exported}");
// Colliding names are refused as in the command, before any file is written.
let clash = root.join("clash");
let refused = h.handle("doc.exportDataSets", json!({"dir": clash.to_string_lossy(), "naming": "{document}"})).unwrap_err().to_string();
assert!(refused.contains("{index}"), "{refused}");
assert_eq!(std::fs::read_dir(&clash).unwrap().count(), 0, "no file of the refused batch");
// A CSV from the read root, and one outside it.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let imported = h.handle("doc.importDataSets", json!({"path": "sets.csv"})).unwrap();
assert_eq!(imported["imported"].as_u64(), Some(2), "{imported}");
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = h.handle("doc.importDataSets", json!({"path": base.join("outside.csv").to_string_lossy()})).unwrap_err().to_string();
assert!(outside.contains("must be inside the automation root"), "{outside}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
@@ -635,6 +781,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()));
+192 -4
View File
@@ -52,6 +52,79 @@ pub struct OpenParams {
pub path: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PlaceParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Scale as a percentage; defaults to fitting a file larger than the canvas.
#[serde(default)]
pub scale: Option<f64>,
/// Place at a centred size instead of a scaled one.
#[serde(default)]
pub fit: Option<bool>,
/// Centre of the placed layer, in document pixels.
#[serde(default)]
pub center: Option<[f64; 2]>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportLayersParams {
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
pub dir: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Export format (default png).
#[serde(default)]
pub format: Option<String>,
/// Filename prefix (default: the document name).
#[serde(default)]
pub prefix: Option<String>,
/// Export only the layers that are visible (default true).
#[serde(default, rename = "visible_only")]
pub visible_only: Option<bool>,
/// Encoder quality, 0..12, for the formats that take one.
#[serde(default)]
pub quality: Option<f64>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportDataSetsParams {
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
pub dir: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Export format (default png).
#[serde(default)]
pub format: Option<String>,
/// Only these data sets, by name; default every one of them.
#[serde(default, rename = "data_sets")]
pub data_sets: Option<Vec<String>>,
/// Filename template with `{name}`, `{index}` and `{document}` (default `{name}`).
#[serde(default)]
pub naming: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportDataSetsParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Field delimiter (default `,`).
#[serde(default)]
pub delimiter: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NewParams {
@@ -94,6 +167,9 @@ pub struct SaveParams {
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
#[serde(default, rename = "tgaBits")]
pub tga_bits: Option<u8>,
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option<u8>,
#[serde(default)]
pub index: Option<usize>,
}
@@ -246,7 +322,8 @@ pub struct UiSetParams {
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error.
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
pub fields: Value,
}
@@ -519,6 +596,107 @@ impl PhotocraftMcp {
to_result(b.call("app.open", json!({"path": p.path})).await)
}
#[tool(
description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own."
)]
async fn doc_place(&self, Parameters(p): Parameters<PlaceParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.scale {
opts.insert("scale".into(), json!(v));
}
if let Some(v) = p.fit {
opts.insert("fit".into(), json!(v));
}
if let Some(v) = p.center {
opts.insert("center".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which reads the path itself like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.place(index, &path, &opts)).await {
return to_result(r);
}
self.run_command("file.placeEmbedded".into(), bridge_params, true).await
}
#[tool(
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
)]
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.format {
opts.insert("format".into(), json!(v));
}
if let Some(v) = p.prefix {
opts.insert("prefix".into(), json!(v));
}
if let Some(v) = p.visible_only {
opts.insert("visibleOnly".into(), json!(v));
}
if let Some(v) = p.quality {
opts.insert("quality".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which writes its own paths like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("dir".into(), Value::String(p.dir.clone()));
}
let (dir, index) = (p.dir, p.index);
if let Some(r) = self.headless_op(move |h| h.export_layers(index, &dir, &opts)).await {
return to_result(r);
}
self.run_command("file.export.layersToFiles".into(), bridge_params, true).await
}
#[tool(
description = "Export every data set of the document as its own flattened file beneath the write root (the capability-scoped Export Data Sets). The batch is planned first, so colliding filenames are refused before any file is written."
)]
async fn doc_export_data_sets(&self, Parameters(p): Parameters<ExportDataSetsParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.format {
opts.insert("format".into(), json!(v));
}
if let Some(v) = p.data_sets {
opts.insert("dataSets".into(), json!(v));
}
if let Some(v) = p.naming {
opts.insert("naming".into(), json!(v));
}
let opts = Value::Object(opts);
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("dir".into(), Value::String(p.dir.clone()));
}
let (dir, index) = (p.dir, p.index);
if let Some(r) = self.headless_op(move |h| h.export_data_sets(index, &dir, &opts)).await {
return to_result(r);
}
self.run_command("file.export.dataSetsAsFiles".into(), bridge_params, true).await
}
#[tool(description = "Import a data set CSV from the read root into the document (the capability-scoped Import Variable Data Sets).")]
async fn doc_import_data_sets(&self, Parameters(p): Parameters<ImportDataSetsParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.delimiter {
opts.insert("delimiter".into(), json!(v));
}
let opts = Value::Object(opts);
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.import_data_sets(index, &path, &opts)).await {
return to_result(r);
}
self.run_command("file.import.variableDataSets".into(), bridge_params, true).await
}
#[tool(description = "Create a new document (defaults: 1920x1080 RGB 8-bit white).")]
async fn doc_new(&self, Parameters(p): Parameters<NewParams>) -> Result<CallToolResult, McpError> {
let mut params = serde_json::Map::new();
@@ -617,7 +795,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
@@ -666,8 +846,9 @@ impl PhotocraftMcp {
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j})), false).await
}
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")]
#[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \
Not an atomic transaction: earlier successful steps stay applied (no rollback).")]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
if p.steps.len() > MAX_BATCH_STEPS {
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
@@ -809,6 +990,11 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
};
let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) {
None => None,
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
@@ -818,6 +1004,7 @@ impl PhotocraftMcp {
p.format.is_some().then_some("format"),
p.quality.is_some().then_some("quality"),
p.tiff_layers.then_some("tiffLayers"),
dpx_bits.is_some().then_some("dpxBits"),
p.index.is_some().then_some("index"),
]
.into_iter()
@@ -838,6 +1025,7 @@ impl PhotocraftMcp {
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
+1 -1
View File
@@ -16,7 +16,7 @@ fn annotated(mut tool: Tool) -> Tool {
tool.name.as_ref(),
"command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot"
);
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open");
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place");
tool.title = Some(title.clone());
tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false)));
Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false));
+87 -2
View File
@@ -2,6 +2,38 @@
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::security::MAX_REQUEST_BYTES;
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
/// executable at EOF after an oversized suffix is rejected.
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
let mut bytes = Vec::new();
loop {
let available = source.fill_buf().await?;
if available.is_empty() {
if bytes.is_empty() {
return Ok(None);
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
let newline = available.iter().position(|&byte| byte == b'\n');
let length = newline.map_or(available.len(), |at| at + 1);
if length > maximum.saturating_sub(bytes.len()) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("request exceeds {maximum} bytes")));
}
bytes.extend_from_slice(&available[..length]);
source.consume(length);
if newline.is_some() {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
}
}
impl PhotocraftMcp {
/// Serve a JSON-lines stream. A malformed line receives -32700/id:null; the next line
/// is still handled. A single writer serializes parse errors and SDK responses.
@@ -14,8 +46,8 @@ impl PhotocraftMcp {
let (service_out, from_service) = tokio::io::duplex(1 << 20);
let (errors, mut error_rx) = tokio::sync::mpsc::channel::<String>(16);
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Some(line) = lines.next_line().await? {
let mut lines = BufReader::new(input);
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
if line.trim().is_empty() {
continue;
}
@@ -59,3 +91,56 @@ impl PhotocraftMcp {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bounded_line_accepts_exact_limit_and_final_line() {
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
assert_eq!(bounded_line(&mut oversized_eof, 5).await.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn oversized_valid_tool_call_never_reaches_dispatch() {
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "admission-test", "version": "1" }
}
});
let mut input = format!("{initialize}\n{}\n", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).into_bytes();
let call = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "doc_new", "arguments": { "width": 8, "height": 8 } }
})
.to_string()
.into_bytes();
assert!(call.len() < MAX_REQUEST_BYTES);
input.extend_from_slice(&call);
// The valid JSON prefix spans multiple BufReader fills; only its whitespace suffix
// crosses the byte limit. No part of this line may be forwarded to rmcp.
input.extend(vec![b' '; MAX_REQUEST_BYTES - call.len()]);
input.push(b'\n');
let server = PhotocraftMcp::headless();
let observer = server.clone();
let result = tokio::time::timeout(std::time::Duration::from_secs(5), server.serve_io(std::io::Cursor::new(input), tokio::io::sink()))
.await
.expect("oversized MCP input should end the session");
assert!(result.unwrap_err().to_string().contains("request exceeds 1048576 bytes"));
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 0, "the oversized tool call must not run");
}
}
+56 -5
View File
@@ -1,6 +1,6 @@
//! Capability-based filesystem policy for untrusted automation paths.
use std::io::Write;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
@@ -51,8 +51,7 @@ impl AuthorizedWorkspace {
if !metadata.is_file() {
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
}
// Bounded reads, and a clear error for a file larger than memory (#375).
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
read_to_observed_length(&mut file, metadata.len())
}
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
@@ -108,6 +107,34 @@ impl AuthorizedWorkspace {
}
}
fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result<Vec<u8>, AutomationError> {
// Do not reserve from a potentially stale or attacker-controlled metadata length. Read in
// chunks, and reject growth beyond the length observed on the opened file before appending.
let mut bytes = Vec::new();
let mut chunk = [0u8; 64 << 10];
loop {
let remaining = observed_length.saturating_sub(bytes.len() as u64);
let ask = chunk.len().min(usize::try_from(remaining.saturating_add(1)).unwrap_or(chunk.len()));
let n = match reader.read(&mut chunk[..ask]) {
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(AutomationError::Io(format!("automation read failed: {e}"))),
};
if n == 0 {
return Ok(bytes);
}
if n as u64 > remaining {
return Err(AutomationError::BadRequest(format!("automation file grew beyond its observed length of {observed_length} bytes")));
}
let needed = bytes.len().checked_add(n).ok_or_else(|| AutomationError::BadRequest("automation file is too large to read".into()))?;
if needed > bytes.capacity() {
let target = bytes.capacity().saturating_add(bytes.capacity() / 2).max(needed).min(usize::try_from(observed_length).unwrap_or(usize::MAX));
bytes.try_reserve_exact(target - bytes.len()).map_err(|e| AutomationError::Io(format!("automation read allocation failed: {e}")))?;
}
bytes.extend_from_slice(&chunk[..n]);
}
}
fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapability>, AutomationError> {
let Some(path) = path else { return Ok(None) };
if path.as_os_str().is_empty() {
@@ -225,6 +252,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
| "file.fileInfo"
| "file.automate.fitImage"
| "file.automate.conditionalModeChange"
| "file.automate.cropAndStraightenPhotos"
| "file.scripts.flattenAllLayerEffects"
| "file.scripts.flattenAllMasks"
| "file.scripts.deleteAllEmptyLayers"
@@ -335,12 +363,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"swatches.export" => params.get("path").is_some(),
"actions.save" | "swatches.export" => params.get("path").is_some(),
"plugin.reload" => true,
_ => false,
}
@@ -465,6 +493,23 @@ mod tests {
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
}
#[test]
fn large_observed_length_does_not_trigger_a_fixed_limit_or_upfront_reservation() {
let mut shortened = std::io::Cursor::new(b"psb");
assert_eq!(read_to_observed_length(&mut shortened, (1 << 30) + 1).unwrap(), b"psb");
}
#[test]
fn read_growth_is_rejected_at_the_observed_length() {
let mut growing = std::io::repeat(b'x').take(65);
let error = read_to_observed_length(&mut growing, 64).unwrap_err().to_string();
assert!(error.contains("grew beyond its observed length of 64 bytes"), "{error}");
let mut allowed = std::io::repeat(b'x').take(64);
assert_eq!(read_to_observed_length(&mut allowed, 64).unwrap().len(), 64);
let mut empty = std::io::Cursor::new(b"x");
assert!(read_to_observed_length(&mut empty, 0).is_err());
}
#[test]
fn open_pcraft_loads_a_directory_below_the_read_root() {
let (inside, _, workspace) = roots("pcraft-dir");
@@ -651,6 +696,8 @@ mod tests {
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
}
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
assert!(authorize_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
assert!(authorize_desktop_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers", "layer.smartObjects.saveContents"] {
assert!(authorize_desktop_engine_command(id, &serde_json::json!({"layer": 19})).is_ok(), "{id}");
}
@@ -718,6 +765,8 @@ mod tests {
("plugin.install", serde_json::json!({"path": " "})),
("swatches.import", serde_json::json!({"path": "/outside/set.aco"})),
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
@@ -727,6 +776,8 @@ mod tests {
("plugin.install", serde_json::json!({"data": "QUJD"})),
("swatches.import", serde_json::json!({"data": "QUJD"})),
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
+100
View File
@@ -331,6 +331,7 @@ async fn agent_play_refuses_a_recorded_file_open() {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let (c, server) = connect_headless(headless).await;
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
@@ -343,3 +344,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();
}
+142 -1
View File
@@ -79,6 +79,10 @@ async fn lists_expected_tools() {
"session_list",
"doc_open",
"doc_new",
"doc_place",
"doc_export_layers",
"doc_export_data_sets",
"doc_import_data_sets",
"doc_save",
"doc_export",
"doc_inspect",
@@ -166,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();
}
@@ -611,6 +621,107 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
cleanup(&base);
}
/// #2810: a file from the read root places as a Smart Object layer; `file.placeEmbedded` itself
/// stays refused, so `doc_place` is the only way in and the root is still the scope.
#[tokio::test(flavor = "multi_thread")]
async fn doc_place_is_capability_scoped() {
let base = tmp("place");
let root = base.join("workspace");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap();
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("inside.png"), &bytes).unwrap();
std::fs::write(outside.join("outside.png"), bytes).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
let placed = call(&client, "doc_place", json!({"path": "inside.png", "center": [10, 10]})).await;
assert_ne!(placed.is_error, Some(true), "{}", text(&placed));
let layers = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(layers, 2, "the placed layer is in the document");
// An absolute path beneath the root is the same file (#2176), so it places too; outside it,
// nothing.
let inside = call(&client, "doc_place", json!({"path": root.join("inside.png").to_string_lossy()})).await;
assert_ne!(inside.is_error, Some(true), "{}", text(&inside));
let placed = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(placed, 3, "the absolute path beneath the root places as well");
let refused = call(&client, "doc_place", json!({"path": outside.join("outside.png").to_string_lossy()})).await;
assert_eq!(refused.is_error, Some(true));
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
let after = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(after, placed, "nothing outside the root was placed");
// The ambient command keeps its refusal: the capability-scoped method is the only way in.
let ambient = call(&client, "command_run", json!({"id": "file.placeEmbedded", "params": {"path": "inside.png"}})).await;
assert_eq!(ambient.is_error, Some(true));
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
client.cancel().await.unwrap();
cleanup(&base);
}
/// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV,
/// and the three ambient commands keep their refusal.
#[tokio::test(flavor = "multi_thread")]
async fn doc_exports_and_data_set_import_are_capability_scoped() {
let base = tmp("export");
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 10, "height": 6, "background": "white", "name": "card"})).await);
let inspected = json_of(&call(&client, "doc_inspect", json!({})).await);
let layer = inspected["layers"][0]["id"].clone();
json_of(
&call(&client, "command_run", json!({"id": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.await,
);
json_of(
&call(
&client,
"command_run",
json!({"id": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.await,
);
let layers = call(&client, "doc_export_layers", json!({"dir": "layers", "prefix": "card"})).await;
assert_ne!(layers.is_error, Some(true), "{}", text(&layers));
assert_eq!(json_of(&layers)["count"], 1, "{}", text(&layers));
assert_eq!(std::fs::read_dir(root.join("layers")).unwrap().count(), 1);
let sets = call(&client, "doc_export_data_sets", json!({"dir": "sets", "format": "png"})).await;
assert_ne!(sets.is_error, Some(true), "{}", text(&sets));
assert_eq!(json_of(&sets)["count"], 2, "{}", text(&sets));
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2);
// A colliding template is refused before any file of the batch is written.
let clash = call(&client, "doc_export_data_sets", json!({"dir": "clash", "naming": "{document}"})).await;
assert_eq!(clash.is_error, Some(true), "{}", text(&clash));
assert_eq!(std::fs::read_dir(root.join("clash")).unwrap().count(), 0);
// A CSV from the read root.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\n").unwrap();
let imported = call(&client, "doc_import_data_sets", json!({"path": "sets.csv"})).await;
assert_ne!(imported.is_error, Some(true), "{}", text(&imported));
assert_eq!(json_of(&imported)["imported"], 1, "{}", text(&imported));
// Outside the roots nothing happens at all.
let escaped = call(&client, "doc_export_layers", json!({"dir": base.join("escaped").to_string_lossy()})).await;
assert_eq!(escaped.is_error, Some(true), "{}", text(&escaped));
assert!(!base.join("escaped").exists());
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = call(&client, "doc_import_data_sets", json!({"path": base.join("outside.csv").to_string_lossy()})).await;
assert_eq!(outside.is_error, Some(true), "{}", text(&outside));
for command in ["file.export.layersToFiles", "file.export.dataSetsAsFiles", "file.import.variableDataSets"] {
let refused = call(&client, "command_run", json!({"id": command, "params": {}})).await;
assert_eq!(refused.is_error, Some(true), "{command}");
assert!(text(&refused).contains("ambient filesystem paths"), "{}", text(&refused));
}
client.cancel().await.unwrap();
cleanup(&base);
}
/// The template workflow stays in memory, including saving nested contents back to the parent.
#[tokio::test(flavor = "multi_thread")]
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
@@ -935,6 +1046,18 @@ async fn bridge_reports_unreachable_app() {
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_description_exposes_non_atomic_semantics_when_tools_are_listed() {
let client = connect(PhotocraftMcp::headless()).await;
let tools = client.list_all_tools().await.unwrap();
let tool = tools.iter().find(|tool| tool.name == "command_batch").expect("command_batch tool");
let description = tool.description.as_deref().expect("command_batch description");
for contract in ["in order", "Not an atomic transaction", "first error", "stop_on_error is false", "no rollback"] {
assert!(description.contains(contract), "command_batch description omits `{contract}`: {description}");
}
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_runs_steps_in_order() {
let client = connect(PhotocraftMcp::headless()).await;
@@ -1111,7 +1234,25 @@ async fn convention_resources_are_live_json() {
assert!(read("photocraft://document").await["document"].is_null());
json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await);
assert_eq!(read("photocraft://document").await["width"], 8);
assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await));
// Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold
// anything, and the other tests in this binary fill and purge those caches concurrently, so
// its `enabled` flag and paired `disabledReason` can flip between the two reads. Validate
// each availability pair; every stable field and every other command must match exactly.
let stable = |mut v: Value| {
for c in v.as_array_mut().into_iter().flatten() {
if c["id"] == "edit.purge.all" {
let reason = if c["enabled"].as_bool().unwrap() { Value::Null } else { json!("nothing to purge") };
assert_eq!(c["disabledReason"], reason, "purge availability stays internally consistent");
let command = c.as_object_mut().unwrap();
command.remove("enabled");
command.remove("disabledReason");
}
}
v
};
let commands = stable(read("photocraft://commands").await);
assert!(commands.as_array().is_some_and(|a| a.len() > 100), "{commands}");
assert_eq!(commands, stable(json_of(&call(&client, "command_list", json!({})).await)));
assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err());
let preview = call(&client, "render_preview", json!({"max_side":4})).await;
assert!(preview.content.iter().any(|c| c.as_image().is_some()));
@@ -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);
}
@@ -0,0 +1,40 @@
//! Save As must store the new filename in the native manifest, not just the in-session tab name.
use photocraft_automation::{Headless, files};
use serde_json::json;
#[test]
fn native_save_as_reopens_with_the_written_filename() {
let dir = std::env::temp_dir().join(format!(
"pc-native-save-name-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let first = dir.join("FirstTest.pcraft");
let second = dir.join("SecondTest.pcraft");
let mut headless = Headless::trusted_local();
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Old Title"})).unwrap();
headless.handle("doc.save", json!({"path": first.to_str().unwrap()})).unwrap();
assert_eq!(files::open(&first).unwrap().document.name, "FirstTest.pcraft");
headless.handle("doc.save", json!({"path": second.to_str().unwrap()})).unwrap();
assert_eq!(headless.session.active().unwrap().doc.name, "SecondTest.pcraft");
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
// Save back in place and retain the correct title after another edit.
headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap();
headless.handle("doc.save", json!({})).unwrap();
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn workspace_encoded_native_bundle_uses_requested_name_without_mutating_source() {
let mut headless = Headless::trusted_local();
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Original"})).unwrap();
let doc = &headless.session.active().unwrap().doc;
let (bytes, warnings) = files::save_bytes(doc, "exports/Renamed.pcraft", None, &Default::default()).unwrap();
assert!(warnings.is_empty());
let reopened = files::open_bytes("Renamed.pcraft", &bytes).unwrap().document;
assert_eq!(reopened.name, "Renamed.pcraft");
assert_eq!(doc.name, "Original", "a copy should not retarget the open document");
}
+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
+22 -2
View File
@@ -4,7 +4,7 @@ Codecs for flat raster image formats, written in pure Rust. The crate is standal
workspace crate and defines its own `Image` type. It also builds for `wasm32-unknown-unknown` and
does no I/O: decoders take `&[u8]` and encoders return `Vec<u8>`.
It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
It follows the "Avoiding GIMP's hole" rules in `docs/architecture.md` §1.1:
* **Symmetric.** Every format we write, we can also read. The only exceptions are listed in
`ASYMMETRIC_EXCEPTIONS`, each with a reason. The test suite checks this.
@@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@@ -61,6 +62,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`
@@ -71,6 +75,19 @@ the same.
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
`CodecError::Unsupported`; Bayer mosaics open undebayered.
* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element,
descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits.
10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data
packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16
`round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No
colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and
colorimetric specification, so a log frame is reported as log. More elements than the first
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.
@@ -101,7 +118,10 @@ the same.
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
TIFF directories and transparency masks are not pages). Any TIFF page can still be opened
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes
from `decode_gif_frames`: each frame composited onto the logical screen with the disposal
methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and
`MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`).
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
`caps.exif = false` for TIFF, and a warning is raised.
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
+8 -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
@@ -62,3 +62,10 @@ path = "fuzz_targets/decode_heif.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_gif"
path = "fuzz_targets/decode_gif.rs"
test = false
doc = false
bench = false
@@ -0,0 +1,13 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_codecs::{decode_as_with, decode_gif_frames, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::Gif, data, &opts);
let _ = decode_gif_frames(data, &opts);
});
+656
View File
@@ -0,0 +1,656 @@
//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the
//! 1664-byte generic header (file and image information, image source), the 384-byte industry
//! header (film and television), then the image data of each element.
//!
//! Reading:
//!
//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian).
//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without
//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words
//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the
//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits
//! (1, method A). Lines start on a 32-bit word boundary.
//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16
//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth.
//! * No colour transform: the transfer characteristic and colorimetric specification are reported
//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred.
//! * The image orientation is applied on import (the caller turns the image upright).
//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a
//! DPX export writes them back.
//!
//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with
//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image
//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one.
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
use crate::options::{DpxBits, EncodeOptions, Limits};
const F: Format = Format::Dpx;
/// Generic plus industry header: where our image data starts.
const HEADER_LEN: usize = 2048;
const GENERIC_LEN: u32 = 1664;
const INDUSTRY_LEN: u32 = 384;
/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float).
const UNDEF32: u32 = u32::MAX;
const UNDEF8: u8 = u8::MAX;
/// Offset of the first image element and the size of one.
const ELEMENT0: usize = 780;
const ELEMENT_LEN: usize = 72;
const MAX_ELEMENTS: u16 = 8;
// Header field offsets (generic file header, image information, film and TV headers).
const CREATION_TIME: (usize, usize) = (136, 24);
const CREATOR: (usize, usize) = (160, 100);
const PROJECT: (usize, usize) = (260, 200);
const COPYRIGHT: (usize, usize) = (460, 200);
const ORIENTATION: usize = 768;
const FRAME_POSITION: usize = 1712;
const FRAME_RATE: usize = 1724;
const TIME_CODE: usize = 1920;
/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip.
pub(crate) const KEY_CREATOR: &str = "DPX:Creator";
pub(crate) const KEY_PROJECT: &str = "DPX:Project";
pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright";
pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime";
pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer";
pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric";
pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition";
pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate";
pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode";
pub(crate) const KEYS: [&str; 9] =
[KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE];
fn err(msg: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, msg)
}
fn unsupported(msg: impl Into<String>) -> CodecError {
CodecError::unsupported(F, msg)
}
/// The file's byte order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Order {
Big,
Little,
}
impl Order {
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
b.get(at..at.checked_add(N)?)?.try_into().ok()
}
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
let s = Self::bytes::<2>(b, at)?;
Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) })
}
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
let s = Self::bytes::<4>(b, at)?;
Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) })
}
/// A 32-bit field that may be undefined (all ones).
fn defined_u32(self, b: &[u8], at: usize) -> Option<u32> {
self.u32(b, at).filter(|&v| v != UNDEF32)
}
fn f32(self, b: &[u8], at: usize) -> Option<f32> {
self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite())
}
}
/// The byte order from the magic number, `None` when it is not DPX.
fn order(b: &[u8]) -> Option<Order> {
match b.get(..4)? {
b"SDPX" => Some(Order::Big),
b"XPDS" => Some(Order::Little),
_ => None,
}
}
/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when
/// empty or undefined (all ones).
fn string(b: &[u8], (at, len): (usize, usize)) -> Option<String> {
let raw = b.get(at..at.checked_add(len)?)?;
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect();
Some(s.trim().to_string()).filter(|s| !s.is_empty())
}
/// The descriptive fields of image element 0 that decoding needs.
#[derive(Debug, Clone, Copy)]
struct Element {
signed: bool,
descriptor: u8,
transfer: u8,
colorimetric: u8,
bits: u8,
packing: u16,
encoding: u16,
data_offset: usize,
eol_padding: usize,
}
struct Header {
order: Order,
width: u32,
height: u32,
elements: u16,
element: Element,
}
fn parse(b: &[u8]) -> Result<Header, CodecError> {
let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?;
let missing = |what: &str| err(format!("the header ends before {what}"));
let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?;
o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?;
let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?;
if !(1..=MAX_ELEMENTS).contains(&elements) {
return Err(err(format!("invalid number of image elements {elements}")));
}
let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?;
let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?;
let e = ELEMENT0;
let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1"));
let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1"));
let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1"));
let data_offset = match field(28)? {
0 | UNDEF32 => image_offset,
v => v,
};
let eol_padding = match field(32)? {
UNDEF32 => 0,
v => v,
};
let element = Element {
signed: field(0)? == 1,
descriptor: byte(20)?,
transfer: byte(21)?,
colorimetric: byte(22)?,
bits: byte(23)?,
packing: half(24)?,
encoding: half(26)?,
data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?,
eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?,
};
Ok(Header { order: o, width, height, elements, element })
}
/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7
/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…).
/// Anything else, undefined included, is taken as the default (1).
pub(crate) fn orientation(b: &[u8]) -> u16 {
let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 };
match o {
1 => 2,
2 => 4,
3 => 3,
4 => 5,
5 => 6,
6 => 8,
7 => 7,
_ => 1,
}
}
/// Bytes in one stored line of `samples` samples, padded to a 32-bit word.
fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option<usize> {
let words = match (bits, packing) {
// Three samples to a word, or one to a 16-bit half word.
(10, 1 | 2) => samples.div_ceil(3),
(12, 1 | 2) => samples.div_ceil(2),
_ => samples.checked_mul(usize::from(bits))?.div_ceil(32),
};
words.checked_mul(4)
}
/// Code values of one stored line.
fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> {
let short = || err("truncated image data");
match (bits, packing) {
(8, _) => {
let src = line.get(..out.len()).ok_or_else(short)?;
for (v, &c) in out.iter_mut().zip(src) {
*v = u16::from(c);
}
}
(16, _) => {
for (i, v) in out.iter_mut().enumerate() {
*v = o.u16(line, i * 2).ok_or_else(short)?;
}
}
(10, 1 | 2) => {
let base = if packing == 1 { 22 } else { 20 };
for (i, v) in out.iter_mut().enumerate() {
let word = o.u32(line, i / 3 * 4).ok_or_else(short)?;
*v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16;
}
}
(12, 1 | 2) => {
let shift = if packing == 1 { 4 } else { 0 };
for (i, v) in out.iter_mut().enumerate() {
*v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF;
}
}
// Packing 0: one bit stream through the line's 32-bit words, high bits first.
_ => {
let mask = (1u64 << bits) - 1;
let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize);
for v in out.iter_mut() {
while have < u32::from(bits) {
acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?);
word += 1;
have += 32;
}
have -= u32::from(bits);
*v = ((acc >> have) & mask) as u16;
}
}
}
Ok(())
}
/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`.
fn widen(c: u16, max: u64) -> u16 {
((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16
}
/// The inverse of [`widen`]: `round(v × max / 65535)`.
fn narrow(v: u16, max: u64) -> u16 {
((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let h = parse(bytes)?;
let e = h.element;
let layout = match e.descriptor {
6 => ChannelLayout::Gray,
50 => ChannelLayout::Rgb,
51 => ChannelLayout::Rgba,
d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))),
};
if e.signed {
return Err(unsupported("data sign 1 (signed samples) is not supported"));
}
if e.encoding != 0 {
return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding)));
}
let packing_ok = match e.bits {
8 | 16 => e.packing == 0,
10 => e.packing <= 2,
12 => e.packing <= 1,
b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))),
};
if !packing_ok {
return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits)));
}
let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 };
limits.check(h.width, h.height, layout, sample)?;
let nc = layout.channels();
let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
let height = h.height as usize;
let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
let too_large = || CodecError::LimitExceeded("image too large".into());
let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?;
let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?;
// Lines run `stride` apart; the last one needs no padding after it.
let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len);
// Some writers pack 8- and 16-bit lines without padding them to a word; take that layout
// when the file is too short for padded lines but holds the unpadded ones.
let tight = per_line * usize::from(e.bits / 8);
if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) {
(line_len, stride) = (tight, tight);
}
if span(stride, line_len).ok_or_else(too_large)? > data.len() {
return Err(err("truncated image data"));
}
let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?];
for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() {
let start = y * stride;
let line = data.get(start..).ok_or_else(|| err("truncated image data"))?;
unpack_line(line, h.order, e.bits, e.packing, row)?;
}
let mut img = match e.bits {
8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?,
16 => Image::from_u16(h.width, h.height, layout, &codes)?,
b => {
let max = (1u64 << b) - 1;
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, max)).collect();
Image::from_u16(h.width, h.height, layout, &wide)?
}
};
if h.elements > 1 {
img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) });
}
if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 {
img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric });
}
img.meta.text = header_text(bytes, h.order, e);
Ok(img)
}
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
let mut text = Vec::new();
let mut put = |k: &str, v: Option<String>| {
if let Some(v) = v {
text.push((k.to_string(), v));
}
};
put(KEY_CREATOR, string(b, CREATOR));
put(KEY_PROJECT, string(b, PROJECT));
put(KEY_COPYRIGHT, string(b, COPYRIGHT));
put(KEY_CREATION_TIME, string(b, CREATION_TIME));
put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string()));
put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string()));
// The industry header is there when the generic header says so and the file holds it.
let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN;
if industry {
put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string()));
put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string()));
put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code));
}
text
}
/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD.
fn format_time_code(v: u32) -> String {
let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF);
if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") }
}
/// The inverse of [`format_time_code`]; `None` for anything else.
fn parse_time_code(s: &str) -> Option<u32> {
if let Some(hex) = s.strip_prefix("0x") {
return u32::from_str_radix(hex, 16).ok();
}
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) {
return None;
}
parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0')))
}
/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise.
pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
match opt {
Some(b) => b.bits(),
None if sample == SampleType::U8 => 8,
None => 16,
}
}
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
match opt {
Some(DpxBits::Bits8) => SampleType::U8,
Some(_) => SampleType::U16,
None if src == SampleType::U8 => SampleType::U8,
None => SampleType::U16,
}
}
struct Writer {
h: Vec<u8>,
}
impl Writer {
fn u8(&mut self, at: usize, v: u8) {
if let Some(b) = self.h.get_mut(at) {
*b = v;
}
}
fn u16(&mut self, at: usize, v: u16) {
if let Some(s) = self.h.get_mut(at..at + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
}
fn u32(&mut self, at: usize, v: u32) {
if let Some(s) = self.h.get_mut(at..at + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
}
/// Marks a run of numeric fields undefined (all ones).
fn undefined(&mut self, from: usize, to: usize) {
if let Some(s) = self.h.get_mut(from..to) {
s.fill(0xFF);
}
}
/// An ASCII string field, cut to leave room for a terminating NUL.
fn string(&mut self, (at, len): (usize, usize), v: &str) {
let ascii: Vec<u8> = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect();
if let Some(s) = self.h.get_mut(at..at + ascii.len()) {
s.copy_from_slice(&ascii);
}
}
}
fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> {
img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty())
}
/// Appends one line's code values, padded to a 32-bit word.
fn pack_line(out: &mut Vec<u8>, codes: &[u16], bits: u8) {
let start = out.len();
match bits {
8 => out.extend(codes.iter().map(|&c| c as u8)),
10 => {
for chunk in codes.chunks(3) {
let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32)));
out.extend_from_slice(&word.to_be_bytes());
}
}
12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())),
_ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())),
}
let written = out.len() - start;
out.resize(start + written.div_ceil(4) * 4, 0);
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let (descriptor, layout) = match img.layout() {
ChannelLayout::Gray => (6u8, ChannelLayout::Gray),
ChannelLayout::Rgb => (50, ChannelLayout::Rgb),
ChannelLayout::Rgba => (51, ChannelLayout::Rgba),
l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))),
};
let bits = bits_for(opts.dpx_bits, img.sample_type());
let codes: Vec<u16> = match (img.sample_type(), bits) {
(SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(),
(SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(),
(SampleType::U16, 10 | 12) => {
let max = (1u64 << bits) - 1;
img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect()
}
(s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))),
};
let per_line = w as usize * layout.channels();
if codes.len() != per_line * h as usize {
return Err(CodecError::encode(F, "the image has the wrong number of samples"));
}
let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 })
.and_then(|l| l.checked_mul(h as usize))
.ok_or_else(|| CodecError::encode(F, "image too large"))?;
let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?;
let mut wr = Writer { h: vec![0; HEADER_LEN] };
// Generic file header.
wr.h[..4].copy_from_slice(b"SDPX");
wr.u32(4, HEADER_LEN as u32);
wr.string((8, 8), "V2.0");
wr.u32(16, total);
wr.u32(20, 1);
wr.u32(24, GENERIC_LEN);
wr.u32(28, INDUSTRY_LEN);
wr.u32(32, 0);
let keep = |key| if opts.embed_metadata { text(&img, key) } else { None };
wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or(""));
wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft"));
wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or(""));
wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or(""));
wr.u32(660, UNDEF32);
// Image information: default orientation, one element.
wr.u16(ORIENTATION, 0);
wr.u16(ORIENTATION + 2, 1);
wr.u32(772, w);
wr.u32(776, h);
let e = ELEMENT0;
wr.u32(e, 0);
wr.u32(e + 4, 0);
wr.u32(e + 8, UNDEF32);
wr.u32(e + 12, ((1u32 << bits) - 1).min(65535));
wr.u32(e + 16, UNDEF32);
wr.u8(e + 20, descriptor);
// No claim about the encoding unless the document came with one.
let code = |key| keep(key).and_then(|v| v.parse::<u8>().ok()).unwrap_or(UNDEF8);
wr.u8(e + 21, code(KEY_TRANSFER));
wr.u8(e + 22, code(KEY_COLORIMETRIC));
wr.u8(e + 23, bits);
wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 });
wr.u16(e + 26, 0);
wr.u32(e + 28, HEADER_LEN as u32);
wr.u32(e + 32, 0);
wr.u32(e + 36, 0);
// The seven unused elements, the image source header's numbers and the industry header's
// numbers are undefined; their strings stay empty.
wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS));
wr.undefined(1408, 1432);
wr.undefined(1620, 1644);
wr.undefined(FRAME_POSITION, 1732);
wr.undefined(TIME_CODE, 1972);
if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::<u32>().ok()) {
wr.u32(FRAME_POSITION, v);
}
if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::<f32>().ok()).filter(|v| v.is_finite()) {
wr.u32(FRAME_RATE, v.to_bits());
}
if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) {
wr.u32(TIME_CODE, v);
}
let mut out = wr.h;
out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?;
for row in codes.chunks_exact(per_line.max(1)) {
pack_line(&mut out, row, bits);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal header for `w`×`h` with element 0 set; image data at 2048.
fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec<u8> {
let mut b = vec![0u8; HEADER_LEN];
let put32 = |b: &mut Vec<u8>, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
let put16 = |b: &mut Vec<u8>, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" });
put32(&mut b, 4, HEADER_LEN as u32);
put32(&mut b, 28, INDUSTRY_LEN);
put16(&mut b, ORIENTATION, 0);
put16(&mut b, ORIENTATION + 2, 1);
put32(&mut b, 772, w);
put32(&mut b, 776, h);
b[ELEMENT0 + 20] = descriptor;
b[ELEMENT0 + 21] = UNDEF8;
b[ELEMENT0 + 22] = UNDEF8;
b[ELEMENT0 + 23] = bits;
put16(&mut b, ELEMENT0 + 24, packing);
put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32);
b[FRAME_POSITION..1732].fill(0xFF);
b[TIME_CODE..1972].fill(0xFF);
b
}
fn words(o: Order, ws: &[u32]) -> Vec<u8> {
ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect()
}
#[test]
fn ten_bit_method_a_and_b_unpack_three_to_a_word() {
for o in [Order::Big, Order::Little] {
for (packing, base) in [(1u16, 22u32), (2, 20)] {
let mut b = header(o, 1, 1, 50, 10, packing);
b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))]));
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}");
}
}
}
#[test]
fn ten_bit_packing_0_is_one_bit_stream_per_line() {
// Four luma samples: 40 bits over two words, the line padded to the second word.
let samples = [1u64, 2, 1022, 1023];
let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24;
for o in [Order::Big, Order::Little] {
let mut b = header(o, 4, 2, 6, 10, 0);
let line = words(o, &[(stream >> 32) as u32, stream as u32]);
b.extend(&line);
b.extend(&line);
let img = decode(&b, &Limits::default()).unwrap();
let want: Vec<u16> = samples.iter().map(|&s| widen(s as u16, 1023)).collect();
assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}");
}
}
#[test]
fn twelve_bit_method_a_keeps_the_top_bits() {
let mut b = header(Order::Big, 2, 1, 6, 12, 1);
b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]);
}
#[test]
fn widen_and_narrow_round_trip_every_code() {
for bits in [10u8, 12] {
let max = (1u64 << bits) - 1;
for c in 0..=max as u16 {
assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}");
}
}
assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16);
}
#[test]
fn orientation_codes_map_to_exif() {
let mut b = header(Order::Big, 1, 1, 6, 8, 0);
for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] {
b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes());
assert_eq!(orientation(&b), exif, "DPX {dpx}");
}
}
#[test]
fn time_codes_round_trip() {
assert_eq!(format_time_code(0x0102_0304), "01:02:03:04");
assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304));
assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000));
assert_eq!(parse_time_code("1:2:3:4"), None);
}
#[test]
fn eight_bit_lines_without_word_padding_still_open() {
// 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8.
let mut b = header(Order::Big, 1, 2, 50, 8, 0);
b.extend_from_slice(&[1, 2, 3, 4, 5, 6]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
}
}
+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()];
+79
View File
@@ -0,0 +1,79 @@
//! Every frame of an animated GIF. [`crate::decode`] keeps the first frame only; this walks the
//! whole animation for callers that can hold it (video layers).
//!
//! The `image` crate composites each frame onto the logical screen and applies the disposal
//! methods, so every returned frame is a full, self-contained picture.
use std::io::Cursor;
use image::codecs::gif::GifDecoder;
use image::{AnimationDecoder, ImageDecoder};
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, Image};
use crate::options::DecodeOptions;
/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise
/// ask for millions of them while staying inside the byte budget.
pub const MAX_GIF_FRAMES: usize = 100_000;
/// One composited frame of an animation and how long it stays on screen.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationFrame {
pub image: Image,
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a
/// multiple of 10 (0 when the file gives none).
pub delay_ms: u32,
}
/// Decodes every frame of a GIF, each as a full RGBA8 image of the logical screen size.
///
/// The first frame must decode. If a later frame is damaged the frames before it are returned
/// and the first one carries [`DecodeWarning::Truncated`]. Dimensions, the frame count
/// ([`MAX_GIF_FRAMES`]) and the total decoded bytes are checked against `opts.limits` before
/// each frame is kept.
pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
let limits = &opts.limits;
let map = |e: image::ImageError| match e {
image::ImageError::Limits(l) => CodecError::LimitExceeded(l.to_string()),
image::ImageError::Unsupported(u) => CodecError::unsupported(Format::Gif, u.to_string()),
e => CodecError::malformed(Format::Gif, e),
};
let mut decoder = GifDecoder::new(Cursor::new(bytes)).map_err(map)?;
let mut il = image::Limits::default();
il.max_image_width = Some(limits.max_width);
il.max_image_height = Some(limits.max_height);
il.max_alloc = Some(limits.max_alloc);
decoder.set_limits(il).map_err(map)?;
let (w, h) = decoder.dimensions();
limits.check_bytes(w, h, 4)?;
let frame_bytes = u64::from(w) * u64::from(h) * 4;
let mut out: Vec<AnimationFrame> = Vec::new();
let mut damaged = false;
for frame in decoder.into_frames() {
let frame = match frame {
Ok(f) => f,
Err(e) if out.is_empty() => return Err(map(e)),
Err(_) => {
damaged = true;
break;
}
};
if out.len() >= MAX_GIF_FRAMES {
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
}
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
}
let (numer, denom) = frame.delay().numer_denom_ms();
let delay_ms = numer.checked_div(denom).unwrap_or(0);
let rgba = frame.into_buffer();
out.push(AnimationFrame { image: Image::from_u8(w, h, ChannelLayout::Rgba, rgba.into_raw())?, delay_ms });
}
if damaged && let Some(first) = out.first_mut() {
first.image.warnings.push(DecodeWarning::Truncated { format: Format::Gif });
}
Ok(out)
}
+3
View File
@@ -1,9 +1,12 @@
pub(crate) mod deep_exr;
pub(crate) mod dpx;
pub(crate) mod exr;
pub(crate) mod exr_cryptomatte;
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)
}
+28 -7
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
@@ -521,8 +525,16 @@ fn adler32_combine(a1: u32, a2: u32, len2: usize) -> u32 {
}
/// Writes an 8-bit palette PNG (PNG-8): `indices` row-major, `palette` RGB entries (≤ 256),
/// optional fully transparent entry (`tRNS`). Used for Indexed Color documents.
pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>) -> Result<Vec<u8>, CodecError> {
/// optional fully transparent entry (`tRNS`) and optional resolution in dpi (`pHYs`, written when
/// both axes are positive and finite). Used for Indexed Color documents.
pub fn encode_indexed(
width: u32,
height: u32,
indices: &[u8],
palette: &[[u8; 3]],
transparent: Option<u8>,
dpi: Option<(f32, f32)>,
) -> Result<Vec<u8>, CodecError> {
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
return Err(CodecError::InvalidImage("index data does not match the size".into()));
}
@@ -538,6 +550,15 @@ pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3
trns[usize::from(t)] = 0;
info.trns = Some(trns.into());
}
if let Some((x, y)) = dpi
&& x.is_finite()
&& y.is_finite()
&& x > 0.0
&& y > 0.0
{
info.pixel_dims =
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
let mut out = Vec::new();
{
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
@@ -556,14 +577,14 @@ mod indexed_tests {
fn png8_roundtrips_through_the_decoder() {
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
let idx = [0u8, 1, 2, 1, 0, 2];
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2)).unwrap();
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
let img = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
assert_eq!(px.data()[11], 0, "transparent entry");
assert!(encode_indexed(3, 2, &[9; 6], &pal, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
}
}
+68 -32
View File
@@ -79,17 +79,43 @@ fn sample_for_maxval(maxval: u32) -> Result<SampleType, CodecError> {
}
}
/// Build an image from raw integer values in `[0, maxval]`.
fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u32>) -> Result<Image, CodecError> {
let sample = sample_for_maxval(maxval)?;
if values.iter().any(|&v| v > maxval) {
return Err(err("sample exceeds maxval"));
/// The decoded raster, built straight in the output representation. It is the only buffer sized
/// by the header, so `Limits::check` on the output budgets all of it: no wider scratch buffer
/// (such as one `u32` per sample) sits beside it (#2938).
struct Samples {
maxval: u32,
sample: SampleType,
bytes: Vec<u8>,
}
impl Samples {
/// Reserve room for `cap` samples (callers bound `cap` by the file length where the header
/// alone could overstate it).
fn with_capacity(maxval: u32, cap: usize) -> Result<Self, CodecError> {
let sample = sample_for_maxval(maxval)?;
let len = cap.checked_mul(sample.bytes()).ok_or_else(|| CodecError::LimitExceeded(format!("{cap} samples do not fit in memory")))?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} bytes for the image")))?;
Ok(Samples { maxval, sample, bytes })
}
match (sample, maxval) {
(SampleType::U8, 255) => Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()),
(SampleType::U16, 65535) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| v as u16).collect::<Vec<_>>()),
(SampleType::U8, m) => Image::from_u8(w, h, layout, values.into_iter().map(|v| ((v * 255 + m / 2) / m) as u8).collect()),
(_, m) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).collect::<Vec<_>>()),
/// Append one raw value in `[0, maxval]`, rescaled to the full range of the output sample.
fn push(&mut self, v: u32) -> Result<(), CodecError> {
let m = self.maxval;
if v > m {
return Err(err("sample exceeds maxval"));
}
match (self.sample, m) {
(SampleType::U8, 255) => self.bytes.push(v as u8),
(SampleType::U8, m) => self.bytes.push(((v * 255 + m / 2) / m) as u8),
(_, 65535) => self.bytes.extend_from_slice(&(v as u16).to_ne_bytes()),
(_, m) => self.bytes.extend_from_slice(&(((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).to_ne_bytes()),
}
Ok(())
}
fn into_image(self, w: u32, h: u32, layout: ChannelLayout) -> Result<Image, CodecError> {
Image::from_raw(w, h, layout, self.sample, self.bytes)
}
}
@@ -102,18 +128,24 @@ fn sample_count(w: u32, h: u32, channels: usize) -> Result<usize, CodecError> {
.ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{h}x{channels} samples do not fit in memory")))
}
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecError> {
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Samples, CodecError> {
let mut out = Samples::with_capacity(maxval, n)?;
if maxval < 256 {
if data.len() < n {
return Err(err("truncated raster"));
}
Ok(data[..n].iter().map(|&v| v as u32).collect())
} else {
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
let Some(raw) = data.get(..n) else {
return Err(err("truncated raster"));
};
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
for &v in raw {
out.push(v as u32)?;
}
} else {
let Some(raw) = n.checked_mul(2).and_then(|len| data.get(..len)) else {
return Err(err("truncated raster"));
};
for c in raw.as_chunks::<2>().0 {
out.push(u16::from_be_bytes(*c) as u32)?;
}
}
Ok(out)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
@@ -163,19 +195,19 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = sample_count(w, h, layout.channels())?;
let values = if matches!(kind, b'5' | b'6') {
let samples = if matches!(kind, b'5' | b'6') {
r.single_ws()?;
read_binary(r.rest(), n, maxval)?
} else {
// Each ASCII sample is a digit and a separator (the last may lack one), so the
// file, not the header, bounds the reservation (#1110).
let mut v = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
let mut v = Samples::with_capacity(maxval, n.min(r.rest().len() / 2 + 1))?;
for _ in 0..n {
v.push(r.uint()?);
v.push(r.uint()?)?;
}
v
};
from_values(w, h, layout, maxval, values)
samples.into_image(w, h, layout)
}
b'7' => decode_pam(&mut r, limits),
b'F' | b'f' => {
@@ -195,17 +227,22 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
return Err(err("truncated raster"));
}
let le = scale < 0.0;
let mut out = vec![0f32; row * h as usize];
for y in 0..h as usize {
// Write native-endian bytes straight into the output buffer: a separate `Vec<f32>`
// would double the budgeted allocation (#2938).
let row_bytes = row * 4;
let mut out = vec![0u8; row_bytes * h as usize];
for (y, src) in data.chunks_exact(row_bytes).take(h as usize).enumerate() {
// PFM rows are stored bottom-to-top.
let dst = (h as usize - 1 - y) * row;
for i in 0..row {
let o = (y * row + i) * 4;
let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]];
out[dst + i] = if le { f32::from_le_bytes(b4) } else { f32::from_be_bytes(b4) };
let dst_start = (h as usize - 1 - y) * row_bytes;
let Some(dst) = out.get_mut(dst_start..dst_start + row_bytes) else {
return Err(err("truncated raster"));
};
for (d, s) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) {
let v = if le { f32::from_le_bytes(*s) } else { f32::from_be_bytes(*s) };
*d = v.to_ne_bytes();
}
}
Image::from_f32(w, h, layout, &out)
Image::from_raw(w, h, layout, SampleType::F32, out)
}
_ => Err(err("unknown Netpbm subtype")),
}
@@ -250,8 +287,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
};
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = sample_count(w, h, layout.channels())?;
let values = read_binary(r.rest(), n, maxval)?;
from_values(w, h, layout, maxval, values)
read_binary(r.rest(), n, maxval)?.into_image(w, h, layout)
}
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
+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();
+10 -2
View File
@@ -145,13 +145,19 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
layouts[0]
}
pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan {
pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan {
let c = caps(format);
let (layout, sample) = (image.layout(), image.sample_type());
match format {
// PNM: integer data can use any layout (PAM); float goes to PFM
// which only has gray and RGB.
Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 },
// DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped.
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
Format::Dpx => Plan {
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
},
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if m.dpi.is_some() && !c.dpi {
w.push(W::DpiDropped);
}
if !m.text.is_empty() && !c.text {
// DPX keeps its own header fields, not free-form text.
let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k));
if m.text.iter().any(|(k, _)| !text_kept(k)) {
w.push(W::TextDropped);
}
}
+26 -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.
@@ -25,6 +27,8 @@ pub enum Format {
Heif,
/// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data.
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
}
/// What a format can hold **and** what this crate reads/writes for it.
@@ -56,6 +60,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 +86,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; 17] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -88,12 +96,14 @@ impl Format {
Format::Tga,
Format::Ico,
Format::Pnm,
Format::Pcx,
Format::Qoi,
Format::OpenExr,
Format::Hdr,
Format::Avif,
Format::Heif,
Format::Fits,
Format::Dpx,
];
pub fn caps(self) -> FormatCaps {
@@ -117,12 +127,14 @@ impl Format {
Format::Tga => "TGA",
Format::Ico => "ICO",
Format::Pnm => "Netpbm",
Format::Pcx => "PCX",
Format::Qoi => "QOI",
Format::OpenExr => "OpenEXR",
Format::Hdr => "Radiance HDR",
Format::Avif => "AVIF",
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
}
}
@@ -139,12 +151,14 @@ 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"],
Format::Avif => &["avif"],
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
}
}
@@ -159,12 +173,14 @@ 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",
Format::Avif => "image/avif",
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
}
}
@@ -207,6 +223,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 },
@@ -215,6 +232,8 @@ pub fn caps(format: Format) -> FormatCaps {
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
}
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
}
}
@@ -263,9 +282,15 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
return Some(Format::Pnm);
}
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
return Some(Format::Dpx);
}
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);
}
+144
View File
@@ -156,6 +156,32 @@ pub enum DecodeWarning {
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
/// themselves (Z and other deep channels) are not part of the flat result.
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
/// Only the first image element of a DPX file was decoded; `total` counts them all.
MoreElements { total: u32 },
/// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes,
/// 255 when undefined). The code values are imported as stored, without a colour transform.
DpxEncoding { transfer: u8, colorimetric: u8 },
}
/// The SMPTE 268M name of a transfer characteristic or colorimetric code.
fn dpx_code_name(code: u8) -> &'static str {
match code {
0 => "user-defined",
1 => "printing density",
2 => "linear",
3 => "logarithmic",
4 => "unspecified video",
5 => "SMPTE 274M",
6 => "ITU-R BT.709",
7 => "ITU-R BT.601 625-line",
8 => "ITU-R BT.601 525-line",
9 => "NTSC composite video",
10 => "PAL composite video",
11 => "Z linear",
12 => "Z homogeneous",
255 => "undefined",
_ => "unknown",
}
}
impl fmt::Display for DecodeWarning {
@@ -175,6 +201,13 @@ impl fmt::Display for DecodeWarning {
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
format = format.name()
),
DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"),
DecodeWarning::DpxEncoding { transfer, colorimetric } => write!(
f,
"DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform",
dpx_code_name(*transfer),
dpx_code_name(*colorimetric)
),
}
}
}
@@ -512,6 +545,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 +566,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 +745,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();
+22 -2
View File
@@ -22,9 +22,11 @@ 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};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
@@ -35,12 +37,13 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
};
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::options::{DecodeOptions, DpxBits, 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::{dpx, 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> {
@@ -71,7 +74,9 @@ 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::Dpx => dpx::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
@@ -89,11 +94,17 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
let o = match format {
Format::Tiff => tiff::orientation(bytes, None),
Format::Heif => 1,
Format::Dpx => dpx::orientation(bytes),
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
};
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.
@@ -108,6 +119,13 @@ pub fn decode_tiff_page(bytes: &[u8], page: usize, opts: &DecodeOptions) -> Resu
finish_decode(img, tiff::orientation(bytes, Some(page)), opts)
}
/// Decodes every frame of a GIF (a still GIF yields one), each a full RGBA8 picture of the
/// logical screen with its delay. [`decode`] keeps the first frame only. See
/// [`MAX_GIF_FRAMES`] and `opts.limits` for what is refused.
pub fn decode_gif_frames(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
crate::codecs::gif_frames::decode(bytes, opts)
}
/// The Orientation (1–8) of the page [`decode`] opens from a TIFF or BigTIFF file; 1 when it is
/// absent, malformed or out of range.
pub fn tiff_orientation(bytes: &[u8]) -> u16 {
@@ -206,7 +224,9 @@ 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::Dpx => dpx::encode(image, plan, opts),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
+62 -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)
}
@@ -106,6 +121,38 @@ pub enum ExrCompression {
Piz,
}
/// Bits per sample of a DPX file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DpxBits {
Bits8,
Bits10,
Bits12,
Bits16,
}
impl DpxBits {
/// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`.
pub fn from_bits(bits: u64) -> Option<Self> {
match bits {
8 => Some(DpxBits::Bits8),
10 => Some(DpxBits::Bits10),
12 => Some(DpxBits::Bits12),
16 => Some(DpxBits::Bits16),
_ => None,
}
}
/// The bits per sample.
pub fn bits(self) -> u8 {
match self {
DpxBits::Bits8 => 8,
DpxBits::Bits10 => 10,
DpxBits::Bits12 => 12,
DpxBits::Bits16 => 16,
}
}
}
/// Options for [`crate::encode`].
#[derive(Debug, Clone)]
pub struct EncodeOptions {
@@ -126,6 +173,8 @@ pub struct EncodeOptions {
/// only when it could pass the 4 GiB a classic TIFF can address.
pub tiff_bigtiff: bool,
pub exr_compression: ExrCompression,
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
pub dpx_bits: Option<DpxBits>,
/// Embed the ICC profile when the format supports it.
pub embed_icc: bool,
/// Embed EXIF/XMP/DPI/text when the format supports it.
@@ -144,8 +193,19 @@ impl Default for EncodeOptions {
tiff_compression: TiffCompression::Deflate,
tiff_bigtiff: false,
exr_compression: ExrCompression::Zip16,
dpx_bits: None,
embed_icc: true,
embed_metadata: true,
}
}
}
/// 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));
}
}
+1
View File
@@ -59,6 +59,7 @@ fn default_build_formats_all_symmetric() {
Format::OpenExr,
Format::Hdr,
Format::Fits,
Format::Dpx,
];
for f in expected {
assert!(caps(f).read && caps(f).write, "{f:?}");
+386
View File
@@ -0,0 +1,386 @@
//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec:
//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept
//! through a round trip, the bit-depth choice on export and the files it refuses (#1049).
use photocraft_codecs::*;
const HEADER: usize = 2048;
const ELEMENT0: usize = 780;
/// A DPX file built field by field.
#[derive(Clone)]
struct Fixture {
big: bool,
width: u32,
height: u32,
descriptor: u8,
bits: u8,
packing: u16,
encoding: u16,
signed: bool,
transfer: u8,
colorimetric: u8,
orientation: u16,
elements: u16,
strings: Vec<((usize, usize), &'static str)>,
frame_position: u32,
frame_rate: f32,
time_code: u32,
/// Code values, interleaved, top line first.
codes: Vec<u16>,
}
impl Fixture {
fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self {
let channels = channels(descriptor);
let max = (1u32 << bits) - 1;
// Spread over the whole range, ends included.
let n = (width * height) as usize * channels;
let codes = (0..n)
.map(|i| {
if i == 0 {
0
} else if i == 1 {
max as u16
} else {
((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16
}
})
.collect();
Fixture {
big: true,
width,
height,
descriptor,
bits,
packing,
encoding: 0,
signed: false,
transfer: 255,
colorimetric: 255,
orientation: 0,
elements: 1,
strings: Vec::new(),
frame_position: u32::MAX,
frame_rate: f32::from_bits(u32::MAX),
time_code: u32::MAX,
codes,
}
}
fn u16(&self, v: u16) -> [u8; 2] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn u32(&self, v: u32) -> [u8; 4] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn line(&self, codes: &[u16]) -> Vec<u8> {
let mut out = Vec::new();
match (self.bits, self.packing) {
(8, _) => out.extend(codes.iter().map(|&c| c as u8)),
(16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))),
(10, 1 | 2) => {
let base = if self.packing == 1 { 22 } else { 20 };
for chunk in codes.chunks(3) {
let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32)));
out.extend(self.u32(w));
}
}
(12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))),
_ => {
// One bit stream, high bits first, cut into 32-bit words.
let mut bits: Vec<bool> = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect();
bits.resize(bits.len().div_ceil(32) * 32, false);
for word in bits.chunks(32) {
out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b))));
}
}
}
out.resize(out.len().div_ceil(4) * 4, 0);
out
}
fn bytes(&self) -> Vec<u8> {
let mut b = vec![0u8; HEADER];
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
put(0, if self.big { b"SDPX" } else { b"XPDS" });
put(4, &self.u32(HEADER as u32));
put(8, b"V2.0");
put(24, &self.u32(1664));
put(28, &self.u32(384));
put(768, &self.u16(self.orientation));
put(770, &self.u16(self.elements));
put(772, &self.u32(self.width));
put(776, &self.u32(self.height));
for el in 0..usize::from(self.elements.min(8)) {
let e = ELEMENT0 + el * 72;
put(e, &self.u32(u32::from(self.signed)));
put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]);
put(e + 24, &self.u16(self.packing));
put(e + 26, &self.u16(self.encoding));
put(e + 28, &self.u32(HEADER as u32));
}
for &((at, len), s) in &self.strings {
assert!(s.len() < len);
put(at, s.as_bytes());
}
put(1712, &self.u32(self.frame_position));
put(1724, &self.u32(self.frame_rate.to_bits()));
put(1920, &self.u32(self.time_code));
let per_line = self.width as usize * channels(self.descriptor);
for line in self.codes.chunks(per_line) {
b.extend(self.line(line));
}
b
}
}
fn channels(descriptor: u8) -> usize {
match descriptor {
6 => 1,
50 => 3,
_ => 4,
}
}
fn layout(descriptor: u8) -> ChannelLayout {
match descriptor {
6 => ChannelLayout::Gray,
50 => ChannelLayout::Rgb,
_ => ChannelLayout::Rgba,
}
}
/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`.
fn expected(c: u16, bits: u8) -> u16 {
(f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16
}
/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12).
fn written_codes(b: &[u8]) -> (u8, u16, Vec<u16>) {
let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]);
let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
let (w, h) = (be32(772) as usize, be32(776) as usize);
let nc = channels(b[ELEMENT0 + 20]);
let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24));
let off = be32(ELEMENT0 + 28) as usize;
let per_line = w * nc;
let stride = match bits {
10 => per_line.div_ceil(3) * 4,
_ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4,
};
let mut codes = Vec::new();
for y in 0..h {
let line = &b[off + y * stride..];
for i in 0..per_line {
codes.push(match bits {
8 => u16::from(line[i]),
10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16,
12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4,
_ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]),
});
}
}
(bits, packing, codes)
}
fn be32_at(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
}
fn opts(bits: Option<DpxBits>) -> EncodeOptions {
EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() }
}
const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)];
#[test]
fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() {
for descriptor in [6, 50, 51] {
for (bits, packing) in COMBOS {
// Widths that leave partly filled words at the end of a line.
let fx = Fixture::new(descriptor, bits, packing, 5, 3);
let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}"));
let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}");
assert_eq!(detect(&fx.bytes()), Some(Format::Dpx));
assert_eq!((img.width(), img.height()), (5, 3), "{what}");
assert_eq!(img.layout(), layout(descriptor), "{what}");
assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}");
match bits {
8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::<Vec<_>>(), "{what}"),
16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"),
_ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::<Vec<_>>(), "{what}"),
}
}
}
}
#[test]
fn big_and_little_endian_files_open_identically() {
for (bits, packing) in COMBOS {
let be = Fixture::new(50, bits, packing, 4, 2);
let le = Fixture { big: false, ..be.clone() };
assert_eq!(&le.bytes()[..4], b"XPDS");
assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}");
}
}
#[test]
fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() {
let mut fx = Fixture::new(6, 10, 1, 1, 1);
fx.codes = vec![512];
assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]);
}
#[test]
fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() {
for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] {
for packing in [0, 1] {
let fx = Fixture::new(50, bits, packing, 7, 3);
let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap();
let (wbits, wpacking, codes) = written_codes(&out);
assert_eq!((wbits, wpacking), (bits, 1));
assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}");
}
}
}
#[test]
fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() {
let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) };
let img = decode(&fx.bytes()).unwrap();
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
assert_eq!(&out[..4], b"SDPX");
let (bits, packing, codes) = written_codes(&out);
assert_eq!((bits, packing, codes), (16, 0, fx.codes));
assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples());
}
#[test]
fn log_frames_report_their_encoding_and_keep_the_plain_mapping() {
let mut fx = Fixture::new(50, 10, 1, 3, 2);
(fx.transfer, fx.colorimetric) = (3, 1);
let img = decode(&fx.bytes()).unwrap();
assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]);
let note = img.warnings[0].to_string();
assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}");
assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::<Vec<_>>());
}
#[test]
fn only_the_first_of_several_image_elements_is_imported() {
let mut fx = Fixture::new(6, 8, 0, 2, 2);
fx.elements = 2;
let img = decode(&fx.bytes()).unwrap();
assert_eq!(img.warnings.iter().map(ToString::to_string).collect::<Vec<_>>(), ["only the first of 2 image elements was imported"]);
}
#[test]
fn listed_header_fields_survive_a_round_trip() {
let mut fx = Fixture::new(50, 10, 1, 2, 2);
fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")];
(fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304);
let img = decode(&fx.bytes()).unwrap();
for (k, v) in [
("DPX:Creator", "Resolve 20.1"),
("DPX:Project", "Reel 7"),
("DPX:Copyright", "(c) 2026 Studio"),
("DPX:CreationTime", "2026:10:12:09:30:00UTC"),
("DPX:Transfer", "3"),
("DPX:Colorimetric", "1"),
("DPX:FramePosition", "86400"),
("DPX:FrameRate", "23.976"),
("DPX:TimeCode", "01:02:03:04"),
] {
assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text);
}
let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap();
let src = fx.bytes();
// Field by field: the strings, the element's encoding codes, film and TV fields.
for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] {
assert_eq!(out[at..at + len], src[at..at + len], "field at {at}");
}
assert_eq!(decode(&out).unwrap().meta.text, img.meta.text);
}
#[test]
fn chosen_bit_depth_sets_the_header_and_packing() {
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::<Vec<_>>()).unwrap();
for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] {
let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap();
assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}");
let back = decode(&out).unwrap();
assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb));
}
// Without a choice the depth follows the image.
let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap();
assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8);
assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16);
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
}
#[test]
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap();
assert_eq!(out[ELEMENT0 + 20], 6);
assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]);
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded));
}
#[test]
fn only_free_form_text_is_reported_as_dropped() {
let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap();
img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())];
assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
img.meta.text.push(("Comment".into(), "from a PNG".into()));
assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped));
}
#[test]
fn stored_orientation_is_turned_upright() {
// Orientation 4: the stored lines are the image's columns, left to right.
let mut fx = Fixture::new(6, 8, 0, 3, 1);
fx.codes = vec![1, 2, 3];
fx.orientation = 4;
let img = decode(&fx.bytes()).unwrap();
assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..]));
}
#[test]
fn unsupported_and_damaged_files_error_instead_of_panicking() {
let unsupported = |bytes: Vec<u8>, what: &str| {
assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}");
};
unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor");
unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding");
unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples");
let mut float = Fixture::new(50, 16, 0, 2, 2).bytes();
float[ELEMENT0 + 23] = 32;
unsupported(float, "32-bit float");
let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes();
method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes());
unsupported(method_b, "12-bit method B");
let good = Fixture::new(51, 10, 1, 9, 9).bytes();
let mut no_elements = good.clone();
no_elements[770..772].copy_from_slice(&0u16.to_be_bytes());
let mut past_end = good.clone();
past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes());
let mut huge = good.clone();
huge[772..780].copy_from_slice(&[0xFF; 8]);
for (what, bytes) in [
("header cut short", good[..900].to_vec()),
("truncated element", good[..good.len() - 1].to_vec()),
("no image elements", no_elements),
("data offset past the end", past_end),
("absurd size", huge),
("magic alone", b"SDPX".to_vec()),
] {
assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}");
}
}
+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");
}
+168
View File
@@ -0,0 +1,168 @@
//! `decode_gif_frames`: every frame of an animated GIF, composited, with its delay.
use photocraft_codecs::{AnimationFrame, CodecError, DecodeOptions, DecodeWarning, Format, Limits, decode, decode_gif_frames};
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
const CLEAR: [u8; 4] = [0, 0, 0, 0];
fn frames(bytes: &[u8]) -> Vec<AnimationFrame> {
decode_gif_frames(bytes, &DecodeOptions::default()).unwrap()
}
fn pixels(f: &AnimationFrame) -> Vec<u8> {
f.image.to_rgba8()
}
/// Solid full-screen frames written by the `image` crate's encoder, `delay_ms` apart.
fn solid_gif(colours: &[[u8; 4]], w: u32, h: u32, delay_ms: u32) -> Vec<u8> {
let mut out = Vec::new();
let mut enc = image::codecs::gif::GifEncoder::new(&mut out);
let list = colours.iter().map(|c| {
let buf = image::RgbaImage::from_pixel(w, h, image::Rgba(*c));
image::Frame::from_parts(buf, 0, 0, image::Delay::from_numer_denom_ms(delay_ms, 1))
});
enc.encode_frames(list).unwrap();
drop(enc);
out
}
/// A palette-index image LZW-packed with a 2-bit minimum code size. Few enough pixels that the
/// code size never grows past 3 bits.
fn lzw2(indices: &[u8]) -> Vec<u8> {
let mut codes = vec![4u8];
codes.extend_from_slice(indices);
codes.push(5);
let (mut acc, mut nbits, mut out) = (0u32, 0, Vec::new());
for c in codes {
acc |= u32::from(c) << nbits;
nbits += 3;
while nbits >= 8 {
out.push((acc & 0xFF) as u8);
acc >>= 8;
nbits -= 8;
}
}
if nbits > 0 {
out.push(acc as u8);
}
out
}
/// One image of [`raw_gif`]: `(x, y, width, height, disposal method, palette indices)`.
type Part<'a> = (u16, u16, u16, u16, u8, &'a [u8]);
/// A hand-built GIF89a: a `w`x`h` screen with the 4-colour global palette red, green, blue,
/// black, and one image per [`Part`]. Frames are 10 cs apart.
fn raw_gif(w: u16, h: u16, parts: &[Part]) -> Vec<u8> {
let mut b = b"GIF89a".to_vec();
b.extend_from_slice(&w.to_le_bytes());
b.extend_from_slice(&h.to_le_bytes());
b.extend_from_slice(&[0b1000_0001, 0, 0]);
b.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]);
for &(x, y, fw, fh, disposal, indices) in parts {
// Graphic control extension: disposal method, 10 cs delay, no transparency.
b.extend_from_slice(&[0x21, 0xF9, 4, disposal << 2, 10, 0, 0, 0]);
b.push(0x2C);
for v in [x, y, fw, fh] {
b.extend_from_slice(&v.to_le_bytes());
}
b.extend_from_slice(&[0, 2]);
let data = lzw2(indices);
b.push(data.len() as u8);
b.extend_from_slice(&data);
b.push(0);
}
b.push(0x3B);
b
}
#[test]
fn a_still_gif_gives_one_frame_equal_to_decode() {
let still = solid_gif(&[RED], 3, 2, 100);
let all = frames(&still);
assert_eq!(all.len(), 1);
assert_eq!((all[0].image.width(), all[0].image.height()), (3, 2));
assert_eq!(pixels(&all[0]), decode(&still).unwrap().to_rgba8());
assert!(all[0].image.warnings.is_empty());
}
#[test]
fn every_frame_comes_back_in_order_with_its_delay() {
let all = frames(&solid_gif(&[RED, GREEN, BLUE], 2, 2, 120));
assert_eq!(all.len(), 3);
for (f, want) in all.iter().zip([RED, GREEN, BLUE]) {
assert_eq!(pixels(f), want.repeat(4));
assert_eq!(f.delay_ms, 120);
}
}
#[test]
fn a_smaller_frame_is_drawn_over_the_previous_one() {
// Frame 2 is one pixel at x = 1, kept ("do not dispose", 1): the screen is red, blue.
let all = frames(&raw_gif(2, 1, &[(0, 0, 2, 1, 1, &[0, 0]), (1, 0, 1, 1, 1, &[2])]));
assert_eq!(all.len(), 2);
assert_eq!(pixels(&all[0]), [RED, RED].concat());
assert_eq!(pixels(&all[1]), [RED, BLUE].concat());
assert_eq!(all[1].delay_ms, 100);
}
#[test]
fn disposal_to_background_clears_the_previous_frame() {
// Frame 1 (disposal 2, restore to background) covers the screen; frame 2 draws one pixel.
let all = frames(&raw_gif(2, 1, &[(0, 0, 2, 1, 2, &[1, 1]), (1, 0, 1, 1, 1, &[2])]));
assert_eq!(pixels(&all[0]), [GREEN, GREEN].concat());
assert_eq!(pixels(&all[1]), [CLEAR, BLUE].concat());
}
#[test]
fn disposal_to_previous_restores_what_was_there() {
// Frame 1 is kept; frame 2 (disposal 3, restore previous) paints one pixel over it, so frame
// 3, which draws elsewhere, sees frame 1 again.
let all = frames(&raw_gif(2, 1, &[(0, 0, 2, 1, 1, &[0, 0]), (0, 0, 1, 1, 3, &[1]), (1, 0, 1, 1, 1, &[2])]));
assert_eq!(pixels(&all[1]), [GREEN, RED].concat());
assert_eq!(pixels(&all[2]), [RED, BLUE].concat());
}
#[test]
fn a_damaged_later_frame_keeps_the_frames_before_it() {
let good = solid_gif(&[RED, GREEN, BLUE], 8, 8, 100);
// Cut inside the last frame's data, so frames 1 and 2 are intact.
let cut = &good[..good.len() - 12];
let all = frames(cut);
assert!((1..3).contains(&all.len()), "{} frames", all.len());
assert_eq!(all[0].image.warnings, [DecodeWarning::Truncated { format: Format::Gif }]);
assert!(all[1..].iter().all(|f| f.image.warnings.is_empty()));
}
#[test]
fn the_first_frame_must_decode() {
assert!(decode_gif_frames(b"", &DecodeOptions::default()).is_err());
assert!(decode_gif_frames(b"GIF89a", &DecodeOptions::default()).is_err());
assert!(decode_gif_frames(&[0xFF; 64], &DecodeOptions::default()).is_err());
}
#[test]
fn every_prefix_of_a_gif_returns() {
let g = solid_gif(&[RED, GREEN, BLUE], 5, 4, 100);
for n in 0..g.len() {
let _ = decode_gif_frames(&g[..n], &DecodeOptions::default());
}
}
#[test]
fn dimension_limits_apply() {
let g = solid_gif(&[RED], 4, 4, 100);
let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() };
assert!(matches!(decode_gif_frames(&g, &opts), Err(CodecError::LimitExceeded(_))));
}
#[test]
fn the_byte_budget_covers_all_frames_together() {
// One 4x4 RGBA frame is 64 bytes: two frames fit in 128, three do not.
let limits = Limits { max_alloc: 128, ..Limits::default() };
let opts = DecodeOptions { limits, ..Default::default() };
assert_eq!(decode_gif_frames(&solid_gif(&[RED, GREEN], 4, 4, 100), &opts).unwrap().len(), 2);
assert!(matches!(decode_gif_frames(&solid_gif(&[RED, GREEN, BLUE], 4, 4, 100), &opts), Err(CodecError::LimitExceeded(_))));
}
+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> {
+1 -1
View File
@@ -60,7 +60,7 @@ fn truncated_files_mostly_error() {
// Chopping off the second half must not silently succeed for formats
// with length-checked rasters.
for (f, bytes) in samples() {
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
assert!(r.is_err(), "{f:?} decoded half a file");
}
+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");
}
+36
View File
@@ -0,0 +1,36 @@
//! #2938: `Limits::max_alloc` budgets the decoded pixel buffer, so the PNM decoder must not build a
//! wider scratch copy (it used to collect one `u32` per sample, four times an 8-bit image) next to it.
//!
//! This file holds a single test so it runs in its own process: it reads the process's peak RSS
//! (`VmHWM`, Linux only) and nothing else may allocate meanwhile.
#[cfg(target_os = "linux")]
fn peak_rss_bytes() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
let kib: u64 = line.trim_start_matches("VmHWM:").trim().trim_end_matches("kB").trim().parse().ok()?;
Some(kib * 1024)
}
#[cfg(target_os = "linux")]
#[test]
fn pnm_decode_peak_memory_stays_near_the_output_size() {
use photocraft_codecs::*;
const SIDE: u32 = 4096; // 16 MiB of 8-bit gray output
let out_bytes = (SIDE * SIDE) as u64;
let mut file = format!("P5\n{SIDE} {SIDE}\n255\n").into_bytes();
file.extend((0..out_bytes).map(|i| (i % 251) as u8));
// Writing 5 resets the peak RSS to the current RSS; skip where the kernel doesn't allow it.
if std::fs::write("/proc/self/clear_refs", "5").is_err() {
return;
}
let Some(before) = peak_rss_bytes() else { return };
let img = decode(&file).unwrap();
let after = peak_rss_bytes().unwrap();
assert_eq!(img.data().len() as u64, out_bytes);
assert_eq!(img.data()[300], (300 % 251) as u8);
// The output buffer plus slack; a `u32`-per-sample scratch copy alone would be 4× it.
let grown = after.saturating_sub(before);
assert!(grown < 2 * out_bytes, "decoding a {out_bytes} byte image raised peak RSS by {grown} bytes");
}
+3
View File
@@ -134,6 +134,9 @@ mod pnm {
mod fits {
cases!(Format::Fits);
}
mod dpx {
cases!(Format::Dpx);
}
mod qoi {
cases!(Format::Qoi);
}
+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");
}
+484 -48
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],
}
}),
@@ -213,19 +215,9 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
p[..3].copy_from_slice(&o);
}
}
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => map_rgb(buf, |c| {
let l = rgb_to_gray(c);
let ws = (1.0 - l * 2.0).clamp(0.0, 1.0);
let wh = (l * 2.0 - 1.0).clamp(0.0, 1.0);
let wm = 1.0 - ws - wh;
let out: [f32; 3] = std::array::from_fn(|i| (c[i] + (shadows[i] * ws + midtones[i] * wm + highlights[i] * wh) / 100.0 * 0.5).clamp(0.0, 1.0));
if *preserve_luminosity {
let l1 = rgb_to_gray(out).max(1e-6);
out.map(|v| (v * l / l1).clamp(0.0, 1.0))
} else {
out
}
}),
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => {
map_rgb(buf, |c| std::array::from_fn(|ch| color_balance_channel([*shadows, *midtones, *highlights], *preserve_luminosity, ch, c[ch])))
}
Adjustment::SelectiveColor { relative, adjustments } => map_rgb(buf, |c| selective_color(c, *relative, adjustments)),
Adjustment::ColorLookup { lut: Some(table), size, tetrahedral, dither, .. } if lut3d_len(*size).is_some_and(|len| table.len() >= len) => {
let (n, w, x0, y0) = (*size as usize, buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
@@ -246,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] {
@@ -276,9 +361,9 @@ pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels_q(m, levels_q(c, x(k), quantum), quantum)).collect();
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &ident })]
}
Adjustment::Curves { master, per_channel, space, black } => {
let m = if *space == ToneSpace::Lab { curve_lut(&[]) } else { curve_lut(master) };
let row = |c: &[CurvePoint]| curve_lut(c).iter().map(|&v| lut(&m, v)).collect();
Adjustment::Curves { master, per_channel, space, black, pencil } => {
let m = if *space == ToneSpace::Lab { curve_lut(&[]) } else { curve_lut_mode(master, *pencil) };
let row = |c: &[CurvePoint]| curve_lut_mode(c, *pencil).iter().map(|&v| lut(&m, v)).collect();
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &[] })]
}
_ => std::array::from_fn(|_| (0..LUT_SIZE).map(x).collect()),
@@ -303,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);
@@ -346,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
@@ -434,6 +530,63 @@ pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f
}
}
/// How far one Color Balance slider at ±100 moves the black or white point (Photoshop 27.11:
/// shadows −100 maps 99.6 of 255 to black, highlights +100 maps 155.5 to white).
const BALANCE_LEVELS: f32 = 0.39;
/// Color Balance on channel `ch` (0 red, 1 green, 2 blue) of `v` (0..1); `tones` are the
/// shadows, midtones and highlights slider triples (cyan–red, magenta–green, yellow–blue,
/// −100..100).
///
/// Reverse-engineered from Photoshop 27.11 (a gray ramp and pure colours through each slider at
/// ±25, ±50 and ±100, with Preserve Luminosity on and off): every slider is a levels-and-gamma
/// curve per channel, exact within 1 level for a single slider. With `a` = slider / 100:
/// - Preserve Luminosity off: a slider only moves its own channel. Midtones is gamma 2^−a.
/// Shadows is gamma 2^(−a/2) after a black point at 0.39·|a| when lowered; highlights is
/// gamma 2^(−a/2) after a white point at 1 − 0.39·a when raised.
/// - Preserve Luminosity on: midtones is gamma 2^(−a/2) on the slider's channel and 2^(a/2) on
/// the other two. Shadows and highlights go by the differences between a tone's three sliders:
/// each channel gets a plain black point at 0.39·(max − its own) for shadows and a plain white
/// point at 1 − 0.39·(its own − min) for highlights (so one raised shadows slider darkens the
/// other two channels, and two raised together darken only the third).
///
/// Tones apply highlights, shadows, then midtones (measured with two and three tones on one
/// channel: within 1 level).
pub fn color_balance_channel(tones: [[f32; 3]; 3], preserve_luminosity: bool, ch: usize, v: f32) -> f32 {
let [shadows, midtones, highlights] = tones;
let black = |v: f32, b: f32| ((v - b) / (1.0 - b)).max(0.0);
let white = |v: f32, w: f32| (v / w).min(1.0);
let mut v = v.clamp(0.0, 1.0);
for (tone, sliders) in [(2, highlights), (0, shadows), (1, midtones)] {
let a: [f32; 3] = sliders.map(|s| (s / 100.0).clamp(-1.0, 1.0));
if preserve_luminosity && tone != 1 {
let own = a.get(ch).copied().unwrap_or(0.0);
let (lo, hi) = a.iter().fold((f32::MAX, f32::MIN), |m, x| (m.0.min(*x), m.1.max(*x)));
v = if tone == 0 { black(v, BALANCE_LEVELS * (hi - own)) } else { white(v, 1.0 - BALANCE_LEVELS * (own - lo)) };
continue;
}
for (src, slider) in sliders.iter().enumerate() {
let a = (slider / 100.0).clamp(-1.0, 1.0);
let own = src == ch;
if a == 0.0 || (!preserve_luminosity && !own) {
continue;
}
v = match (tone, preserve_luminosity) {
(1, false) => v.powf(2f32.powf(-a)),
(1, true) => v.powf(2f32.powf(if own { -a } else { a } / 2.0)),
(0, false) if a < 0.0 => black(v, BALANCE_LEVELS * -a).powf(2f32.powf(-a / 2.0)),
(0, false) => v.powf(2f32.powf(-a / 2.0)),
(2, false) if a > 0.0 => white(v, 1.0 - BALANCE_LEVELS * a).powf(2f32.powf(-a / 2.0)),
(2, false) => v.powf(2f32.powf(-a / 2.0)),
_ => v,
};
}
}
v
}
/// How much a colour belongs to each Selective Color range: reds, yellows, greens, cyans,
/// blues, magentas (by chroma within the hue sector), whites, neutrals and blacks (by lightness).
pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] {
@@ -455,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];
@@ -469,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;
}
}
@@ -688,6 +850,39 @@ pub fn levels_float(ch: &LevelsChannel, v: f32) -> f32 {
ch.out_black + t * (ch.out_white - ch.out_black)
}
/// The LUT of one Curves curve: [`curve_map_lut`] for a pencil-drawn map, else [`curve_lut`].
pub fn curve_lut_mode(points: &[CurvePoint], pencil: bool) -> Vec<f32> {
if pencil { curve_map_lut(points) } else { curve_lut(points) }
}
/// A pencil-drawn Curves map (see [`photocraft_doc::adjust::curve_map`]): straight segments
/// between its entries, constant outside the first/last, clamped to 0..=1, sampled into a LUT.
pub fn curve_map_lut(points: &[CurvePoint]) -> Vec<f32> {
let mut pts: Vec<(f32, f32)> = points.iter().filter(|p| p.input.is_finite() && p.output.is_finite()).map(|p| (p.input, p.output)).collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
let ident = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return (0..LUT_SIZE).map(ident).collect() };
let mut seg = 0;
(0..LUT_SIZE)
.map(|k| {
let xv = ident(k);
if xv <= first.0 {
return first.1.clamp(0.0, 1.0);
}
if xv >= last.0 {
return last.1.clamp(0.0, 1.0);
}
while pts.get(seg + 1).is_some_and(|p| p.0 < xv) {
seg += 1;
}
let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(seg), pts.get(seg + 1)) else { return last.1.clamp(0.0, 1.0) };
let t = if x1 > x0 { (xv - x0) / (x1 - x0) } else { 0.0 };
(y0 + (y1 - y0) * t).clamp(0.0, 1.0)
})
.collect()
}
/// Photoshop curve: a natural cubic spline through the points (it may
/// overshoot between points, as Photoshop's does), constant outside the
/// first/last point, clamped to 0..=1, sampled into a LUT.
@@ -858,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];
@@ -934,6 +1153,46 @@ mod tone_tests {
}
}
/// Photoshop 27.11, measured: Color Balance with one cyan–red slider moved, over a gray ramp
/// (x = 0, 16, …, 240, 255); red and green out (blue follows green on gray).
#[test]
fn color_balance_matches_photoshop() {
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
type Row = (&'static str, f32, bool, [u8; 17], [u8; 17]);
let measured: [Row; 6] = [
("midtones", 50.0, false, [0, 36, 59, 78, 96, 112, 128, 142, 156, 170, 183, 196, 209, 221, 233, 244, 255], X),
("shadows", -100.0, false, [0, 0, 0, 0, 0, 0, 0, 7, 23, 43, 67, 93, 122, 154, 186, 221, 255], X),
("highlights", 100.0, false, [0, 51, 84, 111, 136, 160, 182, 202, 223, 242, 255, 255, 255, 255, 255, 255, 255], X),
("shadows", 50.0, true, X, [0, 0, 0, 0, 17, 37, 57, 77, 97, 117, 137, 157, 177, 197, 216, 236, 255]),
(
"midtones",
100.0,
true,
[0, 36, 59, 78, 96, 112, 128, 142, 156, 170, 183, 196, 209, 221, 233, 244, 255],
[0, 5, 14, 24, 36, 50, 64, 80, 97, 114, 132, 151, 171, 191, 212, 234, 255],
),
("highlights", -100.0, true, X, [0, 26, 53, 79, 105, 132, 158, 184, 211, 237, 255, 255, 255, 255, 255, 255, 255]),
];
// Two sliders in one tone, Preserve Luminosity on (measured): shadows red and blue +50 darken
// only green; highlights red −50 with green +50 lift green by 100 and blue by 50.
let two =
|tones: [[f32; 3]; 3], x: f32| -> [i32; 3] { std::array::from_fn(|ch| (color_balance_channel(tones, true, ch, x / 255.0) * 255.0).round() as i32) };
assert_eq!(two([[50.0, 0.0, 50.0], [0.0; 3], [0.0; 3]], 128.0), [128, 97, 128]);
let hi = two([[0.0; 3], [0.0; 3], [-50.0, 50.0, 0.0]], 128.0);
assert!((hi[0] - 128).abs() <= 1 && (hi[1] - 211).abs() <= 1 && (hi[2] - 159).abs() <= 1, "{hi:?}");
for (tone, amount, preserve, red, green) in measured {
let mut tones = [[0.0f32; 3]; 3];
tones[["shadows", "midtones", "highlights"].iter().position(|t| *t == tone).unwrap()][0] = amount;
for (i, x) in X.iter().enumerate() {
let v = f32::from(*x) / 255.0;
let got = |ch| (color_balance_channel(tones, preserve, ch, v) * 255.0).round() as i32;
let (r, g) = (got(0), got(1));
assert!((r - i32::from(red[i])).abs() <= 1, "{tone} {amount} pl {preserve} at {x}: red {r} vs Photoshop {}", red[i]);
assert!((g - i32::from(green[i])).abs() <= 1, "{tone} {amount} pl {preserve} at {x}: green {g} vs Photoshop {}", green[i]);
}
}
}
#[test]
fn levels_and_curves_apply_each_channel_before_the_master() {
// These two affine tone adjustments describe the same mapping:
@@ -946,8 +1205,13 @@ mod tone_tests {
space: ToneSpace::Rgb,
black: Default::default(),
};
let curves =
Adjustment::Curves { master: line(0.0, 0.5), per_channel: std::array::from_fn(|_| line(0.5, 1.0)), space: ToneSpace::Rgb, black: Vec::new() };
let curves = Adjustment::Curves {
master: line(0.0, 0.5),
per_channel: std::array::from_fn(|_| line(0.5, 1.0)),
space: ToneSpace::Rgb,
black: Vec::new(),
pencil: false,
};
for depth in [None, Some(SampleType::U8), Some(SampleType::U16), Some(SampleType::F32)] {
let ll = tone_luts_depth(&levels, depth);
let cl = tone_luts_depth(&curves, depth);
@@ -1012,6 +1276,7 @@ mod tone_tests {
per_channel: [line(0.0, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
space: ToneSpace::Cmyk,
black: line(0.0, 0.5),
pencil: false,
};
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.8, 0.8, 0.8, 1.0]] };
apply(&a, &mut b);
@@ -1022,6 +1287,7 @@ mod tone_tests {
per_channel: [line(0.2, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
space: ToneSpace::Lab,
black: Vec::new(),
pencil: false,
};
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.2, 0.2, 0.2, 1.0]] };
apply(&a, &mut b);
@@ -1066,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];
@@ -1077,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])];
@@ -1087,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,

Some files were not shown because too many files have changed in this diff Show More