* security(engine): bound script, batch, adjustment, import and export work from file input
Scripts calling scripts share depth and step budgets; batch inside a played action charges the playback tree; destructive adjustments and mask adjustments visit stored tiles when the content bounds are a huge sparse grid; Copy Layers checks group depth; aux flattening and brush import refuse oversized canvases; video footage frames are budgeted, Render Video names frames after the leaf of the document name and reads no linked files for restricted sessions; ATN names are clipped; profile paths are read as regular files only; prefs.set no longer treats colorSettingsX as colorSettings.
* style(engine): rustfmt
* engine: address review - refuse unbounded sparse adjustments, carry batch playback charges, charge played steps to scripts, clip ATN targets, stricter colorSettings paths, cap image-sequence frames
* security(io): charge sparse layers, shape caches and shared decodes
ORA and PSD export refuse dense boxes past a cap; PSD import and ABR mapping decode shared masks/tips once; Affinity and SVG shape caches are charged to the import budget; SVG embedded JPEG size is probed; Krita tiles are charged for the backing tiles they land in.
* fix(io): clippy range pattern; align the sparse Krita test tiles to the backing grid
* io: address review - offset-aware KRA tile budget, saturating stroke reach, OpenRaster entry limit, PSD retained-surface budget, halved PSD gather cap
The CMYK proptests failed on main now and then (the_split_route_agrees_with_the_exact_path
after #8663). Three gaps, each found by the proptests at 5000 cases:
- cmyk.rs: a clipped Dissolve layer blended like Normal; it now shows each pixel fully or not
at all by the compositor's noise, as the RGB compositor does since #3052.
- cmyk.rs: a Dissolve adjustment layer mixed by its opacity; opacity and mask are now the chance
a pixel takes the adjustment, as on the RGB compositor (#3050).
- native.rs: the display/Flatten ink route didn't round to the document's levels after an
adjustment layer, where the RGB compositor and cmyk.rs do; coverage in a pass-through group
then differed by up to half a level.
The test reference model had the same two Dissolve gaps (ref_clip passed no position, so
Dissolve fell back to Normal), which is why random_documents_match_the_reference never caught
the first one. Three named regression tests cover the minimal cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Fix magnetic lasso gradient polarity cost
* Add magnetic lasso gradient polarity regressions
* style: 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>
* skip redundant smart object resamples and per-pixel reads
* resample smart objects only when their re-render fails
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A Light layer is stored under the Brightness/Contrast `brit` key with a
`CgEd` descriptor carrying more settings; it imported as
Brightness/Contrast with only Contrast. A `brit` whose `CgEd` has keys
outside the Brightness/Contrast set now stays Adjustment::Unsupported, is
written back byte for byte, and the import warns naming the layer.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Branch Sync <branch-sync@localhost>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Crop: Delete Cropped Pixels keeps type and smart objects whole (#7116)
crop_doc cropped every surface for_each_surface visits, including the
rendered caches of type layers and smart objects. Those move by shifting
their stored pixels, so the cut stayed and showed as soon as they were
moved. Like Photoshop, Delete Cropped Pixels now deletes pixel-layer data
(and fill caches) only; shapes are re-rendered as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: Delete Cropped Pixels crops stored video frames again (#4956)
The pixel-layer-only crop path skipped a video layer's stored frames and
original footage, so scrubbing brought the cropped pixels back. Crop them
with the displayed content, as before, and test a shrinking crop.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Address CodeRabbit review: the turned crop keeps type and smart objects whole before rotating
The pass that drops what a turned crop deletes before the rotation still cut
every cached surface. The rotation re-renders type and smart objects, but a
smart object whose source is unavailable keeps its cache, so it stayed clipped.
That pass now uses the same pixel-layer-only rule as the crop itself.
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(format): report a failed directory flush after a save instead of ignoring it
atomic_write and the bundle writer discarded the directory fsync result once the rename had published the file, so a save whose rename might not survive a power loss reported success and its recovery data could be discarded. A real flush failure is now returned (the file is in place, the message says the save may not last), so callers keep the document unsaved and its recovery data, and a bundle save stops before removing objects the old manifest needs. A file system that can't flush directories at all (EINVAL, ENOTSUP, Unsupported) still counts as saved. Fixes#4968.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): a folder that can't be opened for a flush is not a failed save
A write-and-search-only folder (no read permission) can't be opened to fsync it, so no flush can be attempted there. Treating that open failure as a failed flush made saves into such folders fail (the directory_symlinks permission tests on Linux). Only a flush that is attempted and fails is reported now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): flush bundle folders like atomic_write, and test a failed manifest flush
The bundle writer flushed the folder with sync_all, which on Apple
platforms is F_FULLFSYNC and fails on SMB shares; it now goes through the
same RealFs::sync_dir as atomic_write (sync_file's plain-fsync fallback,
#1336). open_dir_for_sync and dir_synced are crate-private. A test-only
hook fails the manifest's folder flush: the save fails, and the objects
the previous manifest references stay until the next save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): keep stale previews until the manifest commits; errno 45 only where it is ENOTSUP
A directory save removed a thumbnail or composite the new document no
longer has before writing the new manifest, so a failed manifest flush
left the previous manifest naming a preview that was gone. They are now
removed after the manifest commits, best-effort like unreferenced
objects; the failed-flush test checks the thumbnail survives.
errno 45 counted as "can't flush directories" on every Unix, but it is
ENOTSUP only on Apple platforms and the BSDs (illumos: EDEADLK). Other
Unix targets now accept only EINVAL and ErrorKind::Unsupported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): flush the object folders before every manifest commit
When an object's folder flush failed, a retried save found the object
already written, reused it without flushing its folder, and then
committed a manifest naming it: success was reported while the object's
directory entry was still unconfirmed. Each directory save now flushes
tiles/ and blobs/ once before writing the manifest, so a reused object is
confirmed too. The flush error is shared with write_bundle_file
(confirm_flushed), and a test retries a one-tile save whose tile folder
flush failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): keep stale-preview removal before the manifest commit, as main requires
f77ffa3e moved stale-preview removal after the manifest commit. That
broke the hardening in 2d5a7497 (#4957), whose test
denied_stale_preview_cleanup_preserves_the_previous_manifest requires a
committed manifest never to sit beside a stale preview, so a failed
removal must fail the save. Restore main's ordering; the object-folder
flush and the failed-flush tests stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): flush the composite folder after removing a stale composite
The stale composite/preview.png was removed before the manifest commit,
but nothing flushed composite/, so a crash could bring it back beside
the new manifest. The folder is now flushed when a stale composite was
removed (the thumbnail lives in the bundle root, flushed with the
manifest).
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>
* Drop live distortion tools when switching or closing documents.
Puppet Warp, Liquify, and the other modal distort tools kept session UI on a global prompt, so undo, delete, and pixels leaked across tabs. Cancel the live tool with the document that owns it.
* Fix Liquify isolation test: new documents have nothing to undo.
file.new leaves an empty history, so the second document needs a layer step before edit.undo can prove Ctrl+Z is not swallowed.
* Stop expecting Puppet Warp's preview to follow a new document.
display_doc is keyed to the session's document (#5115), so a File › New
tab no longer shows the old warp. drop_foreign still closes the session.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* PSD smart filters: Sharpen, Dust & Scratches and Hue/Saturation stay editable (#6267)
They came in as psd.unsupportedFilter and were dropped when the smart
object re-rendered (Replace Contents, Relink). They now map to
filter.sharpen.sharpen (no Fltr, keyed by filterID), filter.noise.
dustAndScratches (Rds/Thsh) and image.adjustments.hueSaturation (Clrz,
Hst2 entries with LclR ranges), and are written back the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Apply suggestion from @coderabbitai[bot]
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* PSD smart filters: keep every Hue/Saturation range on import and export
- A repeated `LclR` range (or a second master entry) in `Adjs` would
overwrite the first on import; such a filter now stays verbatim as an
unsupported filter instead.
- Export rejects a colour range given as a non-object (`"reds": 5`),
as the engine's Hue/Saturation params do, instead of dropping it.
- Colorize's 25% saturation default applies to the master entry only;
a colour range's saturation defaults to 0, as in the engine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove DstS and Shrp filter handling from smart_map
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Paste: keep the pasted image on the canvas (#6759)
Paste centred the clipboard image on the view centre with no clamp, so an
image that fit the canvas could land partly off it, and one larger than
the canvas could leave blank edges. Now, per axis, an image that fits stays
wholly inside the canvas, as close to the view centre as it can, and one
larger than the canvas starts at the canvas's top-left edge, as in
Photoshop. Paste Into / Outside still centre on the selection
("clampToCanvas": false). Centres are computed edge by edge in f64, so
extreme coordinates no longer overflow.
* Paste: doc comments for Paste Into and the paste placement tests
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Auto-Align: a locked layer becomes the reference instead of being moved (#7080)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: Auto-Align moves the Background as a normal layer
With the Background selected and a Cylindrical or Spherical result (every
layer is reprojected), or another layer as the reference, the Background's
own position lock made Auto-Align refuse. Like Free Transform and Photoshop,
the Background's built-in position lock no longer pins it: it becomes a
normal layer ("Layer 0") when it has to move. Lock All, Lock Pixels and a
user's Lock Position on any other layer still keep it as the reference.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Address CodeRabbit review: the Auto-Align params doc names the locked layer as the default reference
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>
* Color pickers: pick on press and follow small drags (#7793)
The Color Picker dialog's field and slider, and the Color panel's
field and hue strip, only applied the colour on dragged()/clicked().
egui holds dragged() back until the pointer moves past its click
threshold (~6 pt) or is held a while, so a press picked nothing at
first and small adjustments jumped.
Add widgets::pick_pos, which reads the pointer from the frame the
button goes down (is_pointer_button_down_on), and use it on all four
surfaces. The Color panel now restyles selected type only when the
picked colour changes.
Tests: color_picker_ui::field_and_slider_pick_on_press_and_follow_small_moves,
panels::color_tests::color_panel_field_and_hue_strip_pick_on_press.
* #1 Reverted accidental scoreboard change when regenerating it
#2 Fixed color picker not checking for pointer press, but any mouse click
* Update crates/ui-egui/src/widgets.rs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Color pickers: rustfmt pick_pos
* test(ui): use main's rgb_to_hsb in the colour picker tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Keep shape fill and stroke alpha through PSD round-trip (#6180).
Photoshop colour descriptors have no alpha, so fill opacity was dropped
on save. Write a PhotoCraft-only `pcAl` key for fill colour, and fold
stroke colour alpha into `strokeStyleOpacity` so Appearance still matches.
* Write pcAl only on solid shape fills, not every colour (#6180).
Gradient stops already store alpha in Trns. Putting pcAl on color_to_desc
made Ramp sample that alpha twice. Generic colour descriptors stay
opaque; fill_to_desc adds pcAl only for SoCo.
* Return color_to_desc's match directly so clippy stays clean (#6180).
The leftover let-and-return after dropping the pcAl branch failed
clippy -D warnings on Windows integration.
* Restore rectangle fill and stroke appearance alphas after PSD save (#6180).
The ticket is Appearance after load, then a redraw. Fill already used pcAl.
Stroke folded colour alpha into strokeStyleOpacity and dropped the colour
alpha, so Appearance still showed a maxed stroke colour. Write pcAl on the
original stroke colour, keep the folded opacity for Photoshop, and unfold
on read. Cover it with a rectangle document round-trip and redraw.
* fix(io): ignore a non-finite pcAl alpha from a file
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>
* Perspective Crop: the swap button swaps W and H (#7059)
The W/H swap button called crop_size::swap, which only acts in the Crop tool's W x H x Resolution mode; Perspective Crop never sets that mode, so the button did nothing there. The button now always swaps the fields; X keeps the guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: the test clicks Perspective Crop's ⇄ button
The test now renders the real options bar with the Perspective Crop tool,
clicks ⇄ through the egui_kittest harness and checks that W and H swapped,
instead of calling `swap_fields` directly. It fails if the button goes back
to calling `swap`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Curves: the graph grows with the Properties panel and a resizable dialog (#8984)
The graph was fixed at 300 points. It now takes the width it is given,
no taller than the window leaves room for beside the other rows. The
Image > Adjustments > Curves dialog gets a resize grip and reopens at
the size it was left at.
* Curves: the dialog stops widening where the graph stops; a squeezed size isn't remembered (#8984)
The dialog's resize grip goes no wider than the graph can grow in this
window, so no blank column. A size at the window's limit no longer
replaces the size the user left the dialog at.
* test(ui): keep the Issue #43 doc comment on its own test
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>
`style.presets.new` with `includeBlending: false` and an explicit `opacity` stores only that
opacity, but `apply` with `add: true` skipped the blending block unless the preset had a blend
mode, so the layer kept its opacity. Test: an_opacity_only_style_added_sets_just_the_opacity
(also checks the layer's blend mode and Fill stay).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two PRs that landed together each added [people.ronibalthazar], and a
duplicate table makes the file invalid TOML (ui-egui's build script
warns that it can't parse it, so the About window's names are lost).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The .atn importer kept only bicubic, bilinear and nearest from a
transform's Intr, so a recorded Bicubic Automatic, Sharper or Smoother
Transform Selection replayed with the command's default (bilinear) and
re-exported without its choice. The bicubic variants are now kept (the
transforms already render them as Bicubic), Automatic becomes Bicubic
Automatic and Preserve Details Bicubic, and both transform commands list
the variants (#8697).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Levels, Curves and the other adjustments: ⌥ plus the shortcut opens the dialog at its last settings (#8900)
* Image › Adjustments: ⌥ while choosing an item opens it at its last settings (#8900)
* Tests: the ⌥-click test picks the Image menu's Adjustments row, not the panel tab (#8900)
* Image › Adjustments: an item opened with ⌥ lands in Edit › Search's recents too (#8900)
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
- Color Decontaminate: "Renkli" (colourful) -> "Renkleri Arındır"
- Warp split commands and "Cancel warp": "Çözgü" (textile warp) -> "Çarpıtma"
- Constraint hint: "Shift" was translated as "kaydırma"; keep the key name
- Highlights (tonal range) -> "Parlak Tonlar", as in the other rows
- Luminance -> "Lüminans" (was "parlaklık", which is Brightness)
- Indexed -> "İndekslenmiş", Trilinear -> "Üç Doğrusal"
- Levels In/Out Black/White: distinct "Giriş/Çıkış Siyahı/Beyazı"
- Layer Knocks Out Drop Shadow now says "Alt Gölge" like Drop Shadow
- Contact Sheet II -> "Kontak Sayfası II"
- Fix word order of the clone-source hints, "After (X)", "On Black/White"
- Translate "Preview size" and "Print preview image"
- Keep theme names Solarized and Breeze; Pro Light -> "Pro Açık"
- Add contributor entry for the author
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(clipboard): render copied shapes in destination canvas space
When a copied shape group is placed beyond its source canvas, rebuild its vector caches against the destination canvas and pixel format. Previously a centered paste into a larger document could clip the caches to an empty source-sized frame, leaving no transform bounds and incorrect effects until a point edit refreshed each shape.
Restore missing shape caches and refresh source-clipped caches when canvas dimensions change. Cover Paste and Copy to Document, nested group contents, 8/16-bit destinations, rendered cache equality, transform bounds and movement Undo.
* fix(clipboard): measure shape placement before destination clipping
Restore missing source-space shape caches before measuring copy bounds, then adapt and translate for the destination. Re-render empty caches when shapes move back onto the canvas instead of shifting an empty raster.
Add focused integration coverage for 32 combinations of partial and full destination clipping, grouped shapes, missing caches, 8/16-bit output, centered and point placement, and Undo/Redo. Clippy and WASM checks pass.
---------
Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
After the window's GPU device is lost, nothing can be drawn (#6464). When
saving the unsaved work for recovery keeps failing (a full disk, an
unwritable or missing recovery folder), the app keeps retrying. Quitting
(the title bar's close button, File › Exit, the window manager's close)
then parked the quit behind the unsaved-changes prompt, which can never
show, so the blank window could not be closed. discard_ui::intercept now
lets a quit through once the device is lost, with a warning in the log.
* Slice tool: Style offers Fixed Ratio and Fixed Size (#8360)
The Slice tool's options bar showed "Style: Normal" as plain text, so a
slice could only be drawn freely. It now has the marquee's Style dropdown
(Normal, Fixed Ratio, Fixed Size) with Width and Height, and a new slice
follows it while it is drawn and when it is made, through the marquee's
chrome_ui::marquee_end. The options are kept with the other tool options,
checked when loaded, and reset by Reset Tool.
* Slice tool: a Fixed Size slice has exactly its size, also a pixel or two
A press inside a pixel (10.25) made a 64 x 64 Fixed Size slice 65 x 65
pixels, since its far edge was rounded out. Fixed Size now starts on the
pixel the press is in. A Fixed Size of 1 or 2 pixels was taken for a click
that selects; Fixed Size makes its slice at any size.
* Slice tool: Fixed Ratio keeps its ratio and Fixed Size whole pixels
A fractional drag rounded each edge of a Fixed Ratio slice apart, so 2 : 1
could become 5 x 3. A fixed style now starts on the pixel the press is in
and takes both sides from one scale: a whole-number ratio comes in whole
multiples of it in its lowest terms, any other as close as whole pixels
allow. Fixed Size rounds its Width and Height to whole pixels, in the
field and when the slice is made, so 1.5 px no longer grows to 2.
* Slice tool: Fixed Ratio follows the drag, Fixed Size fields in whole px
Whole multiples of the ratio grew a 1366 : 768 slice in steps of 683 px,
made a 4 x 1 slice from a plain click under 4 : 1, and jumped a 39.8 px
16 : 9 drag to 48 x 27. The longer side now follows the drag to within a
pixel and the shorter side takes the ratio from it. Fixed Size Width and
Height show whole pixels, so the field matches the slice it makes.
* fix(ui): cap text fields and oversized text events
Plain text fields took any amount of text, so pasting a huge string grew
the string (and its laid-out galley) without bound; #2258 fixed this for
numeric fields only. The shared text_field now holds at most 1024
characters unless its caller sets a limit, and the app cuts every text,
paste and IME event to 1 MiB of characters (at a character boundary)
before any widget sees it, so the dozens of hand-built TextEdits are
bounded too.
Part of #2150
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): bound oversized existing text and synthetic text events
A text_field whose value is already past its limit lays out a bounded
copy and writes it back only on an edit; the text-event cap runs after
the control channel's synthetic events join the input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): raw_input_hook caps text the control channel queues
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: write pattern transparency where Photoshop keeps it (#8054)
A pattern defined over transparency (delete the Background, Define
Pattern, then a Pattern Fill layer from it) was saved with its alpha
plane in the virtual memory array list's user mask slot (24), and
Photoshop refused the PSD. Photoshop keeps pattern transparency in the
last (sheet mask) slot and leaves the user mask unwritten (psd-tools
multichannel-pattern-fill.psd, written by Photoshop CC 2019). The same
writer serves `Patt`/`Pat2`/`Pat3`, `.pat` and `.abr`.
Files saved by older builds still open (the reader takes the first
written array after the colour channels as transparency), and saving
them again rewrites their pattern block instead of keeping the raw
bytes, which `block_has_user_mask` detects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: rewrite only legacy pattern blocks, keep unreadable ones (#8054)
When the document's patterns are unchanged, a pattern block with
transparency in the user mask array is now rewritten in place from its
own patterns, and every other block (pattern blocks that fail to decode
included) is kept verbatim. Previously a legacy block forced the
replace-all path, which dropped an unreadable pattern block that an
unchanged save used to keep. Blocks are decoded in order with one shared
budget, as from_global_blocks does.
Addresses CodeRabbit review on #8756.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): WebP export keeps the resolution
Every WebP export with metadata warned that the resolution would be
dropped, and lost it unless the image already had EXIF with resolution
tags. WebP has no resolution field of its own, so both encoders now
write the resolution into EXIF, adding the tags (or a minimal EXIF
block when the image has none), and WebP reports DPI support.
Part of #2399
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): report WebP DPI as dropped when the source EXIF can't be parsed
That block is written as it is rather than replaced, so its metadata
survives and the lost resolution is reported. The metadata test also
checks the rest of the EXIF (Orientation) survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(codecs): spell the WebP metadata test's bytes as escapes
The Orientation tag and the unparseable EXIF block were raw control
bytes, which also made git keep the file's CRLF line endings; both are
escapes now and the file is LF again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): no EXIF block just for WebP's default 72 ppi
A WebP without EXIF already opens at 72 ppi, so writing a minimal EXIF
for the default only turned every web export into the extended (VP8X)
container (and broke the automation test expecting a simple lossy file).
EXIF the image already has still gets its resolution tags updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(codecs): WebP DPI kept by XMP isn't reported as dropped
When the image's EXIF can't be parsed but its XMP records the
resolution, the WebP keeps it through XMP, which readers check first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(curves): 32-bit Curves continue past 0..1 like Photoshop
In a 32-bit RGB document an edited Curves clipped every sample to the
curve's table, so HDR values above 1 (and negative ones) were flattened.
Photoshop extends the curve beyond its end points along its end segments:
the straight curve [[0,0],[255,128]] maps -0.5, 0.5 and 2 to
-0.250980407, 0.250980407 and 1.003921628 (measured in Photoshop 27.11 on
the issue). The CPU compositor, Image > Adjustments and the GPU kernel
now do the same, with the end slopes measured over 16 table steps.
8/16-bit documents and unedited curves are unchanged.
Fixes#7157
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(curves): extend 32-bit curves in RGB documents only
The curve's space is not the document's: a grayscale, CMYK or Lab
32-bit document with an RGB-space curve keeps the table's ends. The
document mode now reaches the decision on the CPU (apply_in, the
compositor) and the GPU (adjustment_program).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A pin drag took the active tool's snapping. With the Move tool that was a
layer move's Smart Guides, which View > Snap doesn't turn off, so pins jumped
to other layers' edges with snapping off. Puppet Warp and Perspective Warp
drags no longer snap, as in Photoshop.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
contained_panics_count_and_graceful_errors_do_not used the 4 s hang
budget meant for fuzzed commands. With RUST_BACKTRACE=1 the panic hook
prints a backtrace, and on loaded Windows runners the first symbol
lookup took long enough (19.6 s) that run_watched returned None instead
of Some(false). The self-test now waits up to 120 s; the fuzz keeps 4 s.
Photoshop records the composite entry of a Levels or Curves Set as
Chnl/Chnl/Cmps and silently leaves the adjustment unchanged when it is
Chnl/Ordn/Trgt, which is what the export wrote. Import already reads both.
Fixes#8543
* feat(guides): New Guide Layout takes lengths with units
view.newGuideLayout took pixels only. Its widths, gutters and margins now
also take text with a unit as typed in Photoshop's dialog ("1 cm",
"0.25in", "12 pt", "3pc", "10%"), at the document's resolution; percent
is of the target's width for columns, gutters and left/right margins and
of its height for rows and top/bottom margins, per artboard. The dialog's
Width and Height fields already pass such text through, so they accept
units now too.
Part of #4984
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(guides): New Guide Layout percent resolves per selected artboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(guides): assert the full guide lists after a selected-artboard layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The grey was clamped to 0..1 at every depth, so a 32-bit (2, 2, 2) came
out as (1, 1, 1). In 32-bit documents it now clamps to 0..16, as
Photoshop 27.11 does; 8 and 16-bit still clip at 1. CPU and GPU both.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>