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(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>
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>
* fix(blend): Soft Light uses the W3C curve over dark backdrops (#8804)
Photoshop's Soft Light moves the backdrop towards D(cb) in the upper half,
and D(cb) is the cubic ((16·cb − 12)·cb + 4)·cb below a quarter, not
sqrt(cb). With sqrt the Soft Light cell of psd-tools rgb-blend-modes.psd
was up to 20 levels too light over the dark rainbow; with the cubic it is
within 4 of Photoshop's merged image.
Same change in compose.wgsl so the GPU compositor stays at CPU parity.
Krita's own soft_light (the sqrt curve) now opens as the approximation and
svg:soft-light / krita:soft_light_svg as the exact match, without warning.
Fixes#8804
* compose: the see-through Soft Light test expects the W3C curve over a dark backdrop (#8804)
The #6232 test computed Photoshop's Soft Light of yellow as sqrt(b) for red and
green. Its backdrop #04170F is below a quarter in both channels, where the W3C
curve (Photoshop's, measured in #8804) uses ((16b - 12)b + 4)b instead.
* security(compose): bound dense working sets and repeated group evaluation
Smart-filter mixing and stroke fitting work tile by tile instead of over the
union of sparse surfaces; render_layer admits its rect like render; hidden
layers no longer disable the proxy and uniform-area shortcuts; Blend If
groups nested deeper than two are judged by their result, so nesting costs
polynomial instead of exponential work; effect builds past an absolute byte
cap are skipped.
* CodeRabbit #8949: exact tile skips in filter_mask, allocated-tile scan in fit_stroke, contrasting backdrop in the nested-group test
- filter_mask mix/unmix: bare tiles (no tiles on either side) skip for
non-alpha formats too when the two surfaces' default pixels are equal;
differing defaults still get processed, so the clone's default pixel is
never left wrong under a non-unit mask
- shape_split fit_stroke: scan the union of the three surfaces' allocated
tiles (exact skip when all three defaults are transparent) instead of
every tile of a 300k-canvas document; non-transparent defaults fall
back to the full scan
- tests: the nested-groups assertion now uses a black backdrop so the
asserted white can only come from rendering through the group chain
fixes CodeRabbit review comments (major/minor) on filter_mask.rs:27,29,
shape_split.rs:126, tests.rs:2354
* compose: skip a BTreeSet in fit_stroke — a sorted Vec compiles under the wasm toolchain (E0599 on ::drain there)
Two places turned an RGB or Lab colour into ink with the built-in coated
CMYK instead of the document's embedded profile: the exact ink path's
tiles (render_tile runs on rayon workers, which never inherit a caller's
with_cmyk_space scope), e.g. a solid fill picked in RGB, and the split
path's stand-ins (fill and effect colours, converted on the calling thread
outside any scope). Both now enter cmyk_space(doc). A test with a synthetic
uncoated profile checks a solid fill and a Color Overlay get the profile's
inks; each half fails on its own without its fix.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
levels_gamma's shadow toe: for gamma ~1.003..1.03 the join point u1 is a
tiny positive f32 whose u1^2.2 underflows to 0, so b = inf and the toe
gives inf*0 = NaN at u = 0 (any 8/16-bit Levels channel at black). The
toe vanishes as gamma -> 1, so the power curve is its limit: use it when
b is not finite. Found by cmyk random_documents_match_the_reference
(proptest, unseeded; the shrunk case is kept in proptest-regressions);
the bug is on main since #5143, not from this batch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- crates/ui-egui/src/layer_style.rs: values_of keeps the PR's method/smoothness keys and source-stop ends, and main's (#7072) unconditional From/To and geometry fields.
- crates/compose/src/cmyk_tests.rs (no textual conflict): add the PR's new `interpolation: None` field to main's CMYK test GradientOverlay literal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The PR gives an Outside stroke no share beneath fully covered pixels on the
CPU, but no test failed without it: under a Normal layer the opaque pixel hides
the stroke anyway. It shows with Blend Interior Effects as Group on a Multiply
layer: main multiplied the red interior with the blue stroke beneath it (black).
New test outside_stroke_under_multiply_layer_leaves_interior_to_backdrop
(raster and filled shape) fails on main's effects.rs, passes with the PR.
The GPU compositor mirrored the old math (outline_share 1 at full coverage, and
1 under an opaque pixel layer); compose.wgsl now matches the CPU. Blend Interior
Effects as Group itself renders on the CPU, so on the GPU this is consistency;
a parity case pins non-Normal layers with an Outside stroke.
Co-authored-by: seotarek <30214297+seotarek@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main merged #5940, #6472, #7838 and #7922 directly. Kept from the batch:
the snapshot/History-follow identity at the history limit (#5940,
c7ef3676a; main still had the 3-tuple HistorySpot) and the strokeless
shape at a 0 px width step (#6472, 962539607; main still added the
stroke). Main already carries the other two fix-ups (34c9d0ea4, dff8bfd0e).
Overlaps with main resolved:
- #5842 vs main's #5568: one path, apply_color, tool-gated as #5842
has it, with #5568's float fill, kept fill opacity and lock skip;
shape_fill_ui's duplicate foreground_changed/gesture dropped; #5568's
tests run with a shape tool.
- #6230 vs main's #5180: one Type tool path click (path_target), no
second type.attachToPath; vertical type does not start type on a path,
as main has it; main's path_at/path_cursor and canvas arm dropped.
- #5452 moved the shape swatch to shape_paint_ui.rs: main's hex field
(#6201) ported there.
- type_extra_cmds::with_text takes main's dpi form, pub(crate) for
type_area_cmds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 32-bit branch cut the slider to floor(|s| * 255) / 255, which only matches
Photoshop near 25 and 50 %: measured at every whole percent from -100 to +100
on a 32-bit document, Photoshop lowers by 1 - floor(|s| * 256) / 256 and raises
by 255 / (255 - floor(s * 254)) taken to 1/256 and capped at 32767/256 (201 of
201 exact). +100 gave 512x where Photoshop gives 128x, +90 9.8x for 9.44x.
It also kept the HSL saturation unclipped, which is inf at HSL lightness 1 and
0 and negative above 1: (2, 0, 0), red at +1 stop, turned NaN with any edit,
Hue +40 alone included, where Photoshop gives (2, 1.333, 0). The 32-bit branch
now scales the chroma around the lightness instead (same result in gamut),
on the CPU and in the shader, in whole numbers on both. Colorize clamps its
saturation before the 32-bit scaling, so a PSD's out-of-range value renders
the same on the GPU.
Tests (fail without this): thirty_two_bit_saturation_steps_match_photoshop,
thirty_two_bit_hue_saturation_keeps_bright_colours, the GPU parity
hdr_hue_saturation_bright_colours. Photoshop-oracle floor 188 -> 190 of 258,
measured after merging main.
Co-authored-by: Timothy Daly <100327160+timothydaly1@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR's test checks L at full opacity only, so it still passes when a and b are left unrounded or
the rounding moves before the opacity mix, or truncates. New test: Curves taking a 140 to 151.4 at
50% opacity in an 8-bit Lab document must show a 146 (145.7 unrounded, 145.5 rounded before the
mix, 145 truncated: all three fail).
Co-authored-by: timothydaly1 <100327160+timothydaly1@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSVC's floor quiets a signalling NaN while branchless_floor keeps its payload, so the Windows job failed the bitwise compare; every other input still compares bit for bit.
crates/engine/src/file_cmds.rs: batch keeps the save-point closure (takes the output path) and also copies the session's authorize hook into each scratch session, as main now does.
crates/raw/src/sensor.rs: both sides cap the memory of retained tiles; kept the existing retained_segment_bytes check (checked multiplication, same limit) and dropped main's duplicate inline loop.
Resolutions: CLI usage, flag lists and flag parsing carry both --tiff-compression (t15) and
--jpeg-format (t16); file.saveACopy, batch, imageProcessor and layersToFiles param specs carry
both tiffCompression and jpegFormat; file_cmds keeps tiff_compression_param and
jpeg_format_param; atn_cmds keeps flag_set/applies_at_canvas_bounds (t15) and UNHOLDABLE (t16),
edit.transform uses the pixel_target guard (t15) with layer_ref(p)? (t16); ui-egui open paths
build ImportOptions (t16) and apply raw_defaults (t15); docs headers/rows unioned; atn tests keep
both sides; tr.tsv "Keep Using CPU" kept once.
crates/io/tests/corpus.rs: kept main's pass_floor 188 (the PR's 187 would lower it; not re-measured here)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
build_mask derives its working area from sparse mask geometry grown by
feather, so two distant changed pixels could span a canvas-sized
rectangle. It then allocated f32 planes (coverage, pixel-mask values,
blur scratch) over that area, gigabytes during even a one-pixel render.
Check the estimated working bytes before the dense allocation and fall
back to the constant default mask above 512 MiB, so pathological
geometry degrades feather detail instead of exhausting memory.
Finding: 8c04c5ef-a5bd-48f4-bfcf-83615bcf11c0 (CWE-770)
When a pattern exceeded the preparation cache budget, every lookup still
called Tile::new_in, expanding the whole sparse pattern into RGBA floats
on each output tile (and possibly concurrently). The cache budget only
controlled retained entries, not this conversion work.
Reject patterns whose full RGBA conversion would exceed an absolute
256 MiB cap on the uncached path, so oversized patterns are skipped
instead of re-expanded per tile. The cap is independent of the cache
budget, so small patterns still convert on demand when nothing is cached.
Finding: cc0b2f77-5578-48ed-aba0-1f3beccd21cd (CWE-400)
Conflicts, both sides kept: main's .act swatches next to the batch's .asl
styles (Open filters, preset extensions), main's Render smart filters next
to the batch's Distort ones (53 mapped), parity doc status lines.
docs/scorecard.md regenerated.
Follow-ups the merge needed: the Distort smart filter readers pass their
enum type to main's stricter enum_name (the type their writers use), and
the CMYK effect tests set main's new StrokeFx overprint and Bevel
gloss_anti_alias.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fx_region_bytes counted the shape plus one map per enabled effect. Strokes
also build an inside distance and one or two outside fields, and overlays
and strokes build no map of their own, so a stroke-heavy document could
stay under the estimate while thrashing, and an overlay-only one could be
clipped though it fits. effects::map_count, next to build_maps_prepared,
counts what it builds; the test checks it equals FxMaps::bytes per layer.
crates/compose/src/effects.rs: kept main's par_units row loop in bevel_maps and ported the PR's change into it: k maps shade and the flat-face level through the gloss contour (lut_at), and the closure returns (hi, sh) without the apply_contour pair.
A render needs every layer's effect maps. On a large document whose
ordinary layers' whole-region maps take more than the effect cache budget
together, building one evicted the next one needed, so every render, of
any size, built them all again: 1.8-4.4 s per render on a 9000x6000
document with three full drop-shadowed layers. When they don't fit, local
maps are now built over the render rect padded by twice the effects'
reach, as a shapeless layer's already are (#1909), which gives the same
pixels there.