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>
The Layers panel drew Effect::label and "Effects" in English even when catalogs already translated them.
Co-authored-by: zhanghaijun01 <zhanghaijun01@bianfeng.com>
* fix(gpu): rebuild the GPU canvas after an out-of-memory error
An uncaptured wgpu error of any kind dropped the GPU canvas for the rest
of the session, so one recoverable Out of Memory on a working device
left every redraw on the CPU until Retry GPU. Dropping the canvas frees
its memory, so after an out-of-memory error a new GPU canvas is now
built at once on the same device, with a fresh health flag and no
dialog, up to three times a session; past that the CPU renderer and the
Retry GPU dialog stay as before. Validation errors and device losses
are unchanged.
Fixes#6564
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(gpu): only wgpu's out-of-memory error is retried
The fault is classified by wgpu's Error::OutOfMemory variant and recorded
as exactly "Out of Memory", so a validation error that mentions memory
keeps its fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(gpu): a later out-of-memory error replaces an earlier error
So the canvas still rebuilds itself when memory ran out after a
validation error in the same frame; a device loss still wins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a recorded Layers-panel drag keeps its drop target by name
Dragging a layer while recording stored layer.moveTo's target as a layer
id, which means nothing in the document the action later plays in, so
playback stopped with "no such layer". The target is now recorded by
name (with targetOccurrence among same-named layers), as layer.select
records its layer, and layer.moveTo accepts a target by name.
Fixes#7845
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): count a drag's same-named targets without the moved layer
Steps are recorded after they run, so a target's occurrence among
same-named layers could shift when the moved layer shares its name. It
is now counted among the other layers only, at recording and at replay,
which the move doesn't reorder. Only a drag of the selected layer is
recorded by name; drags of several layers, or of an unselected one, keep
their ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a moved group's contents don't count as drop targets
Same-named layers inside a moved group also changed order with the
move, so they are left out of the occurrence count with the group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): leave every moved layer out of a named drop target
A move of several layers given a target by name no longer counts the
moved layers (or their contents) as candidates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a recorded ⌥-drag copy beside itself replays by name
A copy leaves its source in place, so the source stays a candidate for
the named target, at recording and at replay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(actions): a recorded copy's own contents don't count as drop targets
The step is recorded after the copy exists; the copy (and, for a group,
its same-named contents) is left out of the occurrence count, as at
replay it doesn't exist yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <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.
* fix(text): lay out a variable font's named instance from its PostScript name
A PSD type style set in a named instance of a variable font, such as
Bahnschrift Light Condensed, keeps the instance only as its PostScript
name ("Bahnschrift-LightCondensed"); its variations are empty. When
PhotoCraft re-laid the layer out, FontDb::select_named_face looked for a
face with that PostScript name, or one whose style and weight matched. A
variable family is a single face, so neither matched, and the text was
laid out at the font's default instance: about 35% wider than the
instance Photoshop drew with Bahnschrift.
select_named_face now falls back to the family's variable faces when the
PostScript name matches no face. It reads each face's fvar named
instances and compares the name with the instance's own PostScript name,
or, when the instance has none, with the name Adobe Technical Note #5902
derives (variations prefix or family name, a hyphen, then the subfamily
without spaces). Names compare without punctuation or case. On a match,
that face is selected and the instance's axis values are added to the
style's variations, keeping any axis the style already sets. Static
faces still match exactly as before, and PSD import is unchanged.
Tests:
- font_names_tests::postscript_name_of_a_named_instance_selects_its_axis_values
rebuilds the bundled Inter at test time as a variable font (fvar with
wght/wdth axes and two named instances, one with its own PostScript
name and one derived) and checks the axis values each name selects, an
unknown name, and a style's own axis value. It needs no installed font
and no font file in the repository.
- font_names_tests::exact_static_face_gets_no_instance_axes checks that
an exact static match gets no axis values.
- tests::postscript_name_of_a_variable_instance_lays_out_at_that_instance
lays out "Hamburgefonstiv 0123" with Bahnschrift (Windows) or Ubuntu
Sans (Ubuntu) when installed and checks that the instance's PostScript
name gives the same width as its axis values. With Ubuntu Sans before
the fix: UbuntuSans-CondensedLight 980.3 px (= the default instance),
its axes 766.4 px; after the fix both are 766.4 px.
A style with only a weight (no PostScript name) is still not turned into
a wght axis value on a variable font; the issue reports that too, but its
fix and test are about the PostScript name, so that is left for a
separate change.
Fixes#8797
* fix(text): try a variable font's named instance before the style fallback
Review pointed out that select_named_face ran the style-and-weight
fallback before the named-instance lookup. A style that carries a named
instance's PostScript name and also a populated font_style matching the
variable face itself ("Regular", 400) selected that face at its default
instance, and the instance's axis values were never added.
Faces are now tried in order: exact PostScript name, named instance,
then style, weight and italic.
Review also noted that the layout-width test only runs when Bahnschrift
or Ubuntu Sans is installed. The synthesized variable font in
font_names_tests now carries an HVAR table that narrows every advance on
wdth, so layout width shows which instance was used. Two tests run
everywhere:
- named_instance_wins_over_a_matching_font_style
- postscript_name_of_a_named_instance_lays_out_at_that_instance
Both fail before this change. The installed-font test is kept as a check
against a real font.
---------
Co-authored-by: John Turnbach <jturnbach@users.noreply.github.com>
FreeBSD (and every other OS without a capture backend) compiles the
screen colour picker's stub capture, which never reads monitor placements
or builds images. The build warned about the unused ScreenImage import,
the never-read Placement fields, find_monitor and count. Compile those
only where a capture backend or the tests use them.
Seen in the FreeBSD aarch64 CI build; checked with `cargo check -p
photocraft --all-targets` on FreeBSD 15.1 aarch64 (no warnings) and macOS.
* Crop default mode: moving the image under the box snaps (#8580)
In the default (non-Classic) crop mode a drag inside the box moves the
image under it, but snapping was switched off for that drag, so content
couldn't be centred in a larger box without leaving for the Move tool.
The drag now snaps like a Classic Mode frame move: the frame moves by the
image's travel the other way over the document, its edges and centre snap
to the document, layers, guides and grid (smart guides when those are
empty), and the pointer is corrected in the press camera, as crop_ui reads
the drag. A turned box still doesn't snap.
Fixes#8580
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Crop image move: smart guides snap when no Snap To target is in reach (#8622 review)
The document's bounds keep the regular targets non-empty, so the smart
guides were never tried: with View › Snap To › Layers off, the box never
snapped to a layer while the image moved under it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Khawar Zamman Wani <wanikhawar@Khawars-MacBook-Air.local>
NaN determinants bypassed the singularity check, while determinant and translation overflow could return Some with a bogus or non-finite inverse. Reject non-finite determinants and computed inverse coefficients so callers can take their existing failure paths.
Cover all six coefficients with NaN and both infinities, determinant overflow, translation overflow with a finite determinant, and valid extreme translations.
Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
* fix(engine): refuse partial Color Balance and Hue/Saturation ATN exports
A `layer.setAdjustment` step changes only the settings it names, but the
Color Balance and Hue/Saturation descriptors written to the ATN carry
every setting and filled absent ones with defaults, so a partial edit
replayed as one that also reset the rest. Strict export now refuses such
a step (with skipUnsupported it is listed in Skipped); complete ones
export and round-trip as before.
Fixes#8531
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse Hue/Saturation ranges missing their own settings
A colour range written without its own hue, saturation and lightness
would replay with the missing ones reset to 0, so strict export refuses
it like a partial master setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse wrong-shaped Color Balance and Hue/Saturation settings
Strict export now checks the triplets, numbers and flags it needs are of
the shape the descriptor writer reads, since it would write anything else
as 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse malformed Hue/Saturation range bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(engine): cover malformed and valid Hue/Saturation range bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): refuse fractional Hue/Saturation range bounds
The descriptor stores them as integers, so a fractional bound would be
rounded on export.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The rasteriser made one coverage pass per distinct colour over the whole
text's ink rect (nine with faux bold), so text with many coloured runs
cost colours x area: 400 colours of faux-bold 48 pt text took 13.0 s.
Each colour's pass now covers only that colour's glyph bounds (all bold
offsets and decorations included) and composites into that part of the
accumulator: 163 ms for the same text. A test compares the visible
(premultiplied) pixels with full-size passes for plain, faux bold,
underlined, rotated and warped text in RGB and CMYK.
Part of #8602
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(type): type.edit with new text keeps the styles of unchanged text
`type.edit {"text"}` replaced the whole string, so every character took
the first run's style and other styled ranges were lost. It now replaces
only the span that differs (after the common prefix and suffix), so the
unchanged text keeps its character and paragraph styles and an added
character takes the style beside it, as the params doc promised.
Fixes#8489
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(type): text inserted at a paragraph's start joins that paragraph
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
DPX at 10 or 12 bits and Cineon (always 10) are planned as U16, so
DepthReduced, which compares sample types, never fired: a 16-bit document
saved at 10 bits lost six bits per sample silently, and a float one was
told it would be reduced to 16-bit.
FidelityWarning::BitsReduced { from, bits } names the stored depth. Float
samples always get it; U16 ones only when some sample is not already a
code value at that depth, so a frame opened from a 10- or 12-bit file
saves back without a warning. U8 widens losslessly and stays silent.
* security(paint): cap distance-spaced steps per segment and procedural tip primitives
* test(paint): count steps through a Cell so the walker closure can be shared
* paint: keep the spacing carry-over when a segment ends at the step cap; lower the tip work budget
* fix(ui-egui): a click on the bottom layer's Smart Filters selects them again (#8764)
The empty space below the last Layers panel row deselects every layer on a
click (#8100). It started under the last layer's own row, so it covered that
layer's effect and Smart Filter rows and, registered after them, took their
clicks: on the bottom smart object (a new document's Layer 0), clicking a
filter deselected everything instead of selecting it.
The click area now starts under everything drawn for the last layer. The
drop zone below it (#1721) is unchanged.
* Contributors: credit drunkeninja42 as Kushagra Sharma
* perf(gpu): reuse filled-shape effect maps during translation
Hash filled-shape outlines in local coordinates so whole-pixel movement can use the existing effect-map translation reuse path. Keep its region-size and exact coverage checks, and invalidate the key when the outline deforms.
Add a shape-key regression and a synchronized styled-shape group benchmark with fresh-render parity verification. On RTX 4070 Laptop, the 1600x1200 fixture improves from 61.336 to 33.120 ms median; a 24 MP full-canvas fixture improves from 223.018 to 184.289 ms. Warm frames rebuild zero effect programs, and the quick fixture matches fresh rendering exactly.
* fix(gpu): stabilize fractional shape effect fingerprints
Quantize only origin-normalized fingerprint coordinates to a 1/65536 pixel grid and canonicalize signed zero. Preserve the render path and exact coverage guard.
Cover fractional anchors and handles, positive and negative integer translations and handle deformation. Exercise fractional origins in the benchmark and document its entry points. All 40 GPU tests, Clippy and WASM checks pass; reused output matches a fresh compositor exactly.
---------
Co-authored-by: kotulsky <7805437+kotulsky@users.noreply.github.com>
* packaging(macos): retry stapling when Apple's ticket lookup drops
The 0.15.0 release's macOS job failed after both notarizations were
accepted: stapling the DMG lost its connection to Apple's CloudKit
ticket service (error 68). Retry stapling up to five times with a
growing pause before failing the job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* packaging(macos): retry stapler validation too
The re-run of the 0.15.0 macOS job stapled the DMG, then lost the
CloudKit connection in `stapler validate` (error 68 again). Retry
staple + validate as a pair in package.sh, and retry the validation
verify.sh does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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)
* security(algo): bound working sets sized by caller-controlled extents
Zero-width inpaint grids, content-aware fill mosaics of turned thin images,
Liquify fields over huge canvases, tiny-scale reduction weights, warp tile
bookkeeping and per-tile source footprints, and windowed filters whose halo
follows an unclamped radius or a stacked Filter Gallery reach all asked for
memory sized by file or parameter input. Each is now bounded; halo
arithmetic saturates instead of overflowing.
* security(algo): budget variants by dimensions, size-check footprints first, widen the tile-halo maths
CodeRabbit follow-up:
- fill_cow settles the mosaic budget on variant dimensions only, so a
variant's image and mask buffers are not built when the mosaic would
exceed MAX_MOSAIC_PIXELS (complete falls back to the image itself).
- warp validates the footprint's pixel area against MAX_FOOTPRINT_PIXELS
before src.has_tiles_in, so an oversized footprint returns without the
tile-grid scan.
- auto_tile widens the input-derived halo arithmetic to i64 and clamps
before the cast, so an i32::MAX radius cannot overflow in debug builds
and the 2048 tile cap stands.
* fix(algo): drop the duplicate mosaic_size call left behind in fill_cow
The variant-budget rework kept both the new dimensions-based budget and
the old variants-based one; the old call no longer compiled after
mosaic_size started taking dimensions. Keep the comment that explains
the fallback.
Delete the coverage tests, the per-language parity checks and the tests
of one language's exact wording. Catalog integrity, placeholder and
plural-form checks stay, as do the engine tests (locale resolution,
plural rules, fallback, language scoping, drawing in a language); those
compare against tr() or the plural rule rather than catalog wording.
Docs describe the report instead of a failing test.
The report prints, per language, the source keys its catalog lacks (one
line for a language with none). `cargo xtask ci` runs it as its last step
and never fails on it: a missing translation shows in English. A report
that can't run prints a warning.