* DNG: lossy compression 34892 (baseline JPEG tiles)
Adobe's lossy DNG (compression 34892) decodes: each tile is a complete
baseline JPEG (8-bit luma at tile size), decoded by zune-jpeg into the
tile geometry (a mismatch is an error), widened 0xFF -> 0xFFFF like
Photoshop's read of 8-bit CFA data. Predictors and the rest of the
pipeline are unchanged.
testgen grows LossyJpegTiles (image-rs encodes the Q60 fixtures behind
the testgen feature), so CI runs a decode oracle: a smooth ramp stays
within a few quantisation steps of the 8-bit source, and a tile cut
mid-stream fails cleanly.
* Lossy DNG: check the tile JPEG header before decoding; zune deps go workspace
decode_headers() runs and the (width, height) == (s.w, rows) check fires
before decode(), so a hostile SOF cannot allocate the decoder's buffers
(up to 16384x16364) before the size mismatch is caught. The mismatch has
a regression test that swaps a 32x32 tile JPEG for a 16x16 one. zune-core
and zune-jpeg move to [workspace.dependencies] next to image and flate2.
* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)
* Lossy DNG: 3-sample LinearRaw (YCbCr) decodes to RGB; tiles size-check
Real lossy DNGs are 3-sample YCbCr LinearRaw, not single-channel CFA:
the tile now decodes through zune-jpeg's YCbCr-to-RGB output when
SamplesPerPixel is 3 (gray stays for CFA), the decoded length is checked
against the tile's sample count, and other sample counts say unsupported.
The generator writes RGB tiles for 3-sample specs. A LinearizationTable
maps the decoded samples in the develop stage as before - nothing to add
in the sensor. Test: a 3-sample ramp with 64-step channel separation
(surviving Q60 chroma quantisation) decodes within the quality band.
egui takes a plain row's frame stroke off its padding but draws a hovered
row with the stroke, so a row grows by two stroke widths under the pointer.
The popup keeps the widest width it has seen, so scrolling the widest option
under the pointer widened the list by 2 pt. The shared dropdowns now size the
open list to the widest option as drawn hovered from the first frame.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Dragging a dock splitter only traded height with the next expanded group,
so it hard-stopped once that group hit its minimum. resize() now shrinks
the groups on the side the splitter moves into in turn, nearest first,
each down to its minimum (skipping collapsed groups, the filler taking its
share implicitly), and grows the group on the other side by what they
gave. Works both ways; the column height stays the same.
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Middle-clicking a document tab closes that document, as in a browser. Both
tab strips share one rule (`tab_close_clicked`): the x, or a middle click
anywhere on the tab, including over the x. It routes through the same guarded
`file.close` as the x and the context menu, so a document with unsaved
changes still gets the Save / Don't Save / Cancel prompt.
Opening-file tabs are untouched: their x still cancels the job.
The filter dialogs open on their first number since #1757: typing replaces
it and Enter applies it. Image Size and Canvas Size draw their own bodies
and were left out; they now go through the same once-per-dialog focus,
moved into dialogs::first_field_focused.
generated with Claude Code
winit 0.30 gives an undecorated window WS_CAPTION | WS_BORDER and hides the
non-client area by spreading the client rect over it (rust-windowing/winit#2419).
On some Windows systems the compositor still reserves that frame, so the content
is drawn offset by the caption and border while input stays at the client origin:
black bars at the window's top-left and a pointer that no longer matches what it
points at.
Strip WS_CAPTION | WS_BORDER from the window (keeping WS_THICKFRAME and the window
buttons, so edge resizing and Aero snap keep working) and recalculate the frame,
restoring winit 0.27's undecorated behaviour. The guard runs every frame from
raw_input_hook, so a style winit restores on a flag change (maximize, restore, …)
is taken off again. Decorated windows (the system title bar preference) are left
alone.
The card painted a fixed 340×132 rect and laid its rows out with no height
constraint, so a title that wrapped (long names, long translations) pushed
the percentage and Cancel onto the backdrop. The title and message are now
single-line truncated labels (the full text on hover), and the card is
painted after its rows are laid out, growing from the 132 pt minimum to fit
them. It stays centred at the height it needed last frame, settling in one
repaint.
The regression test fails on the old layout (the title wrapped to three
lines) and asserts the title is one line and both the title and Cancel lie
inside the card.
On the web eframe forwards the Ctrl/Cmd+V key-down and then the browser's
paste event (Event::Paste when the clipboard holds text) in the same frame.
clipboard_keys turned the event into a second press, so edit.paste ran twice.
A clipboard event right after its own command key press now counts as that
press; the same goes for Ctrl/Cmd+X with Cut and Ctrl/Cmd+C with Copy.
Desktop is unchanged: egui-winit never sends both.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Below 100 % the preview filtered every k-th source pixel with unscaled
params, so strokes, grain and textures showed several times too large.
It now runs the stack at full resolution on the visible region (the
filters read their reach around it, and noise and textures are seeded by
document position, so the pixels equal the same crop of the applied
result), then averages k x k blocks down to the zoom.
On the desktop the render runs on a worker thread with a progress bar in
the zoom bar; the last preview stays up and moves with the view, a stale
render is cancelled once the settings settle, and closing the dialog
cancels it. Web and inline sessions render synchronously.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
A text field copies and pastes on egui's Copy / Cut / Paste events, which egui-winit makes from
the window's key. With the native menu bar, AppKit takes ⌘C / ⌘X / ⌘V as Edit › Copy, Cut or
Paste's key equivalent whenever that item is enabled, and the key reaches egui as a plain key
press, which a text field ignores. So pasting text into a field (a dialog's text input, a panel
search box) worked only while PhotoCraft's own clipboard was empty and Edit › Paste disabled.
While typing, ⌘C / ⌘X presses become Copy / Cut, and a ⌘V press asks the window for the
clipboard text, which arrives as a Paste event. egui-winit never sends these presses itself
(it sends the events instead), so this changes nothing without the native menu. It covers the
type tool's editor as well as egui text fields.
Co-authored-by: Trade-Mottoes <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Warp Text: the dialog opens with the type layer's current warp
Type > Warp Text... always started at Arc with Bend 50, whatever warp
the layer had, so OK without a change replaced a Flag warp with Arc.
The dialog is now the schema dialog of type.warpText: it starts from
the shown type layer's style, bend, distortions and orientation (None
for a layer without warp, as in Photoshop) and keeps the targets it
opened on (selected type layers or the inline edit). Remembered dialog
values never override the layer's warp.
* Warp Text: the dialog previews the warp live on the canvas
The Warp Text dialog showed its result only after OK. It now has the
schema dialogs' Preview checkbox and live canvas preview; Cancel leaves
the document as it was and OK stays one history step.
The preview of a large document runs on a reduced proxy, but Warp Text
renders the type again from the layer model, at full size: the preview
showed the text k times too large and out of place. Warp Text runs on
the full-size copy and its result is reduced instead.
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(ui-egui): ui.key with ctrl does not trigger shortcuts off the Mac
`ui.key` and `ui.pointer` built egui's modifiers with `command` and `ctrl` as
independent fields. Off the Mac they are the same physical key: egui asks for
both to carry the same value and matches shortcuts on `command`, so a request
with only `ctrl` set produced a state a real key press never produces and
matched nothing. `ui.key {"key":"N","ctrl":true}` replied ok and did nothing,
while `command: true` opened New Document.
One `modifiers_from` helper now serves both: off the Mac the two flags are
paired, on the Mac they stay apart as ⌘ and Control. Callers already sending
`command` are unaffected.
* contributors: add my own entry (GitHub profile name)
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Fixed a bug where if you select a mask and hit the delete key, it deletes the image
* Delete on a targeted mask: fill the selection with the background colour; delete the mask only with no selection
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(layers): keep Background semantics across merge, duplication and canvas size
Preserve destination locks in Merge Down, share the normal Background-copy rule with multi-layer duplication, and convert a Background to a paintable layer when extending its canvas transparently.
Add depth-aware regression coverage and ordinary-layer controls for #2380, #2381 and #2384.
* refactor(layers): copy lower-layer locks without cloning Copy data
* Format background_layer_consistency test
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Make large pixel operations allocation-fallible
* Warp: drop the fixed 400 MP cap; fallible reservations already cover large transforms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
A sweep of every previewed command (default params plus small and large
pixel sizes, on smooth, sharp and noisy images) compared the 1/4 proxy
preview with what OK applies. Still mismatched after #2207:
- Fixed pixel features, now previewed at full resolution: Add Noise,
Reduce Noise (fixed kernel radii), Custom (5x5 kernel), De-Interlace
(scan lines), Trace Contour (1 px lines), Wind (fixed streaks), Extrude
(depth in px), Tree (6 px branch segments), all 47 Filter Gallery
filters, and Indexed Color / Bitmap when they dither.
- Lens Correction's perspective horizontal/vertical and Tree's and
Picture Frame's size are relative, not pixels: no longer divided by k.
- Negative lengths (Offset left/up, Displace) were clamped to +0.1.
- Shadows/Highlights' radii were not scaled.
- Mosaic rounds its cells: k now divides the cell size evenly.
The new every_preview_shows_what_ok_applies test checks all previewed
commands (about 3 s); sweep_all_previews (ignored) prints the table.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(layers): scrub opacity and fill by dragging labels (#2361)
* test(layers): compare translated scrub labels as text
* test(layers): query egui label roles for scrub gestures
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Layer label scrub test: find the text label by its value on the popup arrow's row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Layers panel New Group button always ran layer.new.group, so
Shift-click made an empty group too. Photoshop documents Shift-click on
that button as grouping the selected layers (same as Layer > Group
Layers). Shift-click now runs layer.groupLayers; a plain click still
adds an empty group, as in Photoshop.
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
With a 3,593-layer PSD open, an open menu rebuilt its 809 items every
frame in about 700 ms. Each item's enabled/checked state looks up the
active layer several times, and Document::layer built the whole layer
list (one allocated path per layer) for every lookup.
- Document::layer and path_of search the tree directly: 265 us -> 23 us
per lookup on that file, 700 ms -> 87 ms per menu build.
- An open menu reuses its items between frames, rebuilding when the
native menu's state hash changes or on a click or key press. The hash
now also covers a running background job.
Claude-Session: https://claude.ai/code/session_01TABvQWhsoDmc14uLCnkBJJ
Co-authored-by: Claude Opus 5.5 <[email protected]>
A DeviceLink's header PCS field holds its output colour space
(ICC.1:2010 §7.2.7), so a CMYK→CMYK link has a 4→4 AToB0. The LUT
channel check hard-coded 3 outputs and rejected such files as invalid,
so users were told a well-formed profile was corrupt and never reached
the "DeviceLink profiles cannot be used" error.
Check DeviceLink LUTs against the header's output space instead, and
only let a Lab DeviceLink through link_stages when its tables end in
3 channels, so a Lab→CMYK link gets the unsupported error rather than
building a mismatched transform.
Fixes#2337
Claude-Session: https://claude.ai/code/session_01X5k8jSoLtGswanmHBipHER
Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Colorize floored saturation at 25% on both the CPU (compose::adjust::hue_saturation) and the
GPU shader, so dragging Saturation to 0 with Colorize ticked still tinted the image. Photoshop
leaves only the lightness at 0%. The floor is gone from both paths, so 1-24% now tint
progressively too.
The floor also stood in for Photoshop's 25% starting saturation when Colorize is switched on.
The dialog's checkbox already sets that; the hueSaturation params now do the same when a call
turns Colorize on without giving a saturation, so {"colorize": true, "hue": 30} still tints.
Fixes#2326.
Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Blend modes: Fill below 100% works inside the blend for Photoshop's special eight
In Photoshop, Fill and Opacity are the same for most blend modes, but not
for eight: Color Burn, Linear Burn, Color Dodge, Linear Dodge (Add),
Vivid Light, Linear Light, Hard Mix and Difference. PhotoCraft treated
Fill as Opacity for all of them, so a Difference layer at 50% Fill gave
(150,90,110) where Photoshop gives (150,60,10).
Measured on Photoshop 27.11 (scripted, 24 cases at Fill 25/50/75% with
two colour pairs): below 100% Fill, Photoshop pulls the layer's colour
toward the mode's neutral colour (black for Color Dodge, Linear Dodge,
Difference; white for Color Burn, Linear Burn; 50% gray for Vivid and
Linear Light) and blends that at full strength; Hard Mix becomes
clamp((b - f(1 - t)) / (1 - f)). Opacity still mixes the result in.
That reproduces 31 of 32 values within 1 level (Hard Mix at 75%: 3).
CPU (psblend::composite_fill, the final blend of plain pixel, shape and
type layers) and GPU (Kernel::Blend takes Fill in the spare uniform slot)
agree; Dissolve keeps Fill as coverage. Layers with effects, adjustment
layers and groups keep the old behaviour for now.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* GPU: composite_fill with a single blend_rgb call, so fs_blend stays small
The first version added a third inlined copy of blend_rgb (every blend
mode) to fs_blend: composite_g's two plus blend_rgb_fill's. Windows CI
(software WARP adapter) then timed out rendering the UI in
arbitrary_rotation_preview, which passes on main; a larger shader
compiles noticeably slower there.
composite_fill now handles every case itself with one blend_rgb call
(Fill as coverage for normal modes, the special-eight blend, Lab Normal
mixing, the text gamma space) and is fs_blend's only composite path,
leaving it with fewer inlined copies than on main. Same results: the 35
GPU parity tests pass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The bevel Contour element mapped the height through contour(min(v / range, 1)),
the glow transfer. At the default 50 % range that flattened a Linear contour to
a bevel half as wide as Photoshop's: on a real 18 px smooth inner bevel at
136 % depth the top-edge highlight was gone 15 px in, where Photoshop's is
still 0.19 at 16 px. The contour alone matches that file (bad pixels 0.38 % ->
0.03 %, max error 63 -> 25 / 255) and lowers the bad pixels of every contour
oracle (bevel-inner-contour-ramp-peak 25.9 -> 21.8 %, -double-ring-aa-r70
25.2 -> 21.0 %, bevel-pillow-chisel-hard-gloss-and-contour 11.4 -> 7.2 %);
the other 23 bevel oracles are unchanged. What Range does instead is open.
CPU (compose) and GPU (fx) share the new bevel_contour_lut.
Co-authored-by: Justin Campos <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop
With Use Legacy on, PhotoCraft applied contrast first and then added
the brightness, so any setting that moves both sliders came out wrong:
brightness +40 with contrast +30 mapped mid-grey 128 to 168, where
Photoshop gives 185 (up to 17 levels darker across the midtones).
Measured on Photoshop 27.11 (scripted, a gray ramp through seven
settings): Photoshop adds the brightness, then scales contrast around
128/255. Contrast 80 keeps 128 at 128; the 0.5 pivot moved it to 130.
With both changes every measured value matches within 1 level, on the
CPU and in the GPU adjustment kernel.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Legacy B/C: brightness after contrast when contrast is lowered
The corpus caught it: psd-tools' brightnesscontrast_legacy_grayscale.psd
(brightness -23, contrast -26) matched Photoshop with the old order and
was 6 levels off with brightness first. Photoshop adds the brightness
before contrast when contrast is raised (measured: +40/+30 maps 128 to
185) and after it when contrast is lowered. The measured cases with
lowered contrast had brightness 0, so they could not tell.
CPU and GPU kernel (brightness after contrast goes in p0.w). The io
corpus oracle is back at its floor; the 34 GPU parity tests pass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Filter > Render > Tree... and Picture Frame... failed from their dialogs
with "invalid type: floating point `1.0`, expected u32": the schema
dialog sends every range value as a JSON float, and TreeSpec/FrameSpec
deserialize baseTreeType, lightDirection, frame, lines and seed as u32
through serde. The render commands now round finite floats for those
integer fields (and Flame's seed) before deserializing, like the
engine's `int` helper does for other commands; negative or too-large
values are still refused as invalid parameters.
Tests: a render_cmds regression test (whole floats draw what integers
draw, fractions round, hostile values fail) and a filter_dialog sweep
that opens every schema dialog with its defaults and runs OK and the
preview, failing on any float type error. Only Tree and Picture Frame
failed it.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
- Hue, Saturation, Color and Luminosity no longer clip 32-bit documents to 0..1, so values above 1 survive in the CPU compositor (blend_rgb_hdr now uses blend_rgb_within at f32::MAX) and in the GPU shader (under the existing F_HDR flag). 8/16-bit results are unchanged apart from rounding at white.
- ClipColor returns the grey when the luminosity is outside the in-gamut range, instead of dividing by max − lum rounding noise. The old formula turned colours inside out (Luminosity of a grey of 2 over (0.5, 0.25, 0.125) gave (1.0, 2.32, 2.99)), and CPU and GPU disagreed (Hue over a grey of 2: CPU 1.0, GPU 2.82).
- Tests: 8/16-bit inputs match the ISO 32000 formula, HDR cases in compose, psblend::with_hdr nesting, and a CPU/GPU parity test across depths.
Co-authored-by: hideoneric <[email protected]>
From the menu these Smart Object commands failed with "pass `path`": no file
dialog was wired to them. Replace Contents now asks for a file (by contents,
so it works on the web too) and updates every instance; Export Contents and
Convert to Linked ask where to save, suggesting the contents' own file name.
The engine gains replace_contents_bytes and contents_of for the UI to call.
Relink to File is left to #2416, which adds its picker.
Co-authored-by: Claude Opus 5.5 <[email protected]>
`pick_save` now records the folder of every path the user chooses in a save
or export dialog, per document, and starts that document's next bare-name
dialog (exports, an untitled Save As) there. Precedence: a caller's explicit
directory, then the remembered folder, then beside the document (#1866),
then the shell's default. Save As of a saved document still suggests its own
path. The memory is session-only, keyed by DocId, pruned of closed documents
at the next dialog, and not fed by the web's download-name answers, which
carry no folder.
convert_document treated a Duotone document as plain gray: it converted
the stored gray pixels through the gray profile and dropped the inks, so
the result was neutral gray instead of the inked look the canvas shows.
Fill, text and effect colours stored in Duotone mode were not converted
at all.
Before converting to a colour space, a Duotone document is now inked
into sRGB (Duotone::render, the same model the display uses, with the
inks' sRGB colours), then converted through photocraft-cms to the
destination profile like any RGB document. Grayscale still keeps the
underlying gray. Works per layer at every depth; undo restores the
Duotone document.
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Opening a 32-bit Targa turned its alpha into layer transparency: the
colours of transparent pixels were hidden, and since #2157 only an alpha
channel becomes a Targa's alpha on save, a TGA opened and saved again
lost its alpha.
Measured on Photoshop 27.11 (scripted, files built byte by byte): a
32-bit Targa opens as an opaque Background plus an "Alpha 1" channel
holding the fourth byte, also when the descriptor declares 0 alpha bits;
the stored colours stay; a 24-bit Targa has no channel. PhotoCraft now
opens Targas the same way, so they round-trip with #2157's export.
Image sequences (video layers) still treat a TGA frame's alpha as
transparency, as footage does: load_frames moves the channel back.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* OpenEXR/HDR export: linearize grayscale documents too
Fixes#2386. The EXR/HDR branch of export_flat converted RGB images to
linear sRGB but left gray ones as stored, so an sGray #808080 was written
as 0.502 instead of 0.216. Gray images are now converted from their
profile (sGray when untagged) to linear gray. An untagged grayscale
OpenEXR opens tagged linear gray (an EXR Y channel is linear luminance),
and the open policy keeps that profile without a mismatch prompt, as it
already does for linear sRGB.
* contributors: add itsF4LCON
---------
Co-authored-by: Brandon Thomas <[email protected]>
Save As to a flat format always made the written file the document's
file: a layered or PSD-backed document saved as JPEG was renamed to the
JPEG and marked saved, so Ctrl+S then opened Save As with JPEG chosen,
and closing no longer asked about the unsaved layers. app.save on the
desktop did the same for any format, unlike the headless server (#1547).
Save As now writes a copy when the flat file can't hold the document
(anything beyond one plain raster layer, or the document already lives in a layered
PSD/PSB/.pcraft/OpenRaster/TIFF file), as Photoshop does. A flat
document not in a layered file still becomes the flat file. app.save
only retargets for layered formats, through a helper shared with the
headless server (file_cmds::layered_extension).
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Close each document as it is answered when closing several (#2547)
Quit, Close All and Close Others asked about each unsaved document but
only removed it from the prompt's list; nothing closed until every
document was answered. After "No" the answered tab stayed open (and
often active), so it looked like the same file was being asked about
again.
Now each answered document closes right away (after its save, for Yes)
and the next one asked about becomes the active tab, as Photoshop does.
The parked command still runs at the end: quitting is allowed and Close
All closes the clean rest even if its original target already closed.
Cancel stops there and keeps the unanswered documents open. Single-file
Close and Revert are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Show the first document asked about when closing several (#2547)
The first question of a Quit, Close All or Close Others prompt now
switches to the tab it asks about too, so every question shows its own
document. Before, the tab active when the command ran stayed up for the
first question, often a different file with the same name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(document): keep layer comps valid across document duplication
Remap saved comp and Last Document State references when cloned layers receive new IDs, including historical states and nested groups. Drop obsolete comp metadata in merged-only copies instead of carrying references to removed layers.
Add focused engine regressions for #2382 and related copy workflows.
* style(test): apply rustfmt layout to layer-comp regression tests
Fill Path painted the whole work path even when a selection excluded
part of it. The path's coverage (after anti-aliasing and feather) is now
multiplied by the selection mask, so rectangular, elliptical and
feathered selections all limit the fill. With no selection the whole
path fills, as before.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Exports copied the source EXIF with only Orientation and resolution
patched, so a camera JPEG's embedded thumbnail (IFD1) came along. Finder,
Android and upload pickers show that thumbnail instead of decoding the
image, so an edited photo kept its original preview.
export_exif now clears IFD0's next-IFD pointer, unlinking IFD1. Nothing
else moves, so every other offset stays valid; JPEG, PNG, WebP and the PSD
EXIF resource all go through it.
Co-authored-by: vivekprojects-GIT <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(compose): Preserve native CMYK fills through PSD round trips
Share depth-quantized ink samples between rendering and PSD caches.
Use the document profile for RGB fill colours and preserve valid imported
caches. Uncached CMYK fills use CPU rendering.
Cover all sample depths, gradient styles and tiled renders. Fix the real
16-bit gradient round trip and raise the psd-tools floor from 307 to 308.
* perf(raster): Reuse exact 8-bit CMYK conversions within rows
Cache the previous ink bytes and ICC result per row, with alpha decoded
independently. Keep the 16/32-bit conversion loops unchanged.
Matched 1 MP reads improve by 77% for smooth ramps, 33% with dithering
and 10% for random colours. Bitwise regression coverage includes all
depths, alpha and tile boundaries.
Stroke Path only honoured the transparency lock, so Brush, Pencil and
Eraser painted on a layer whose pixels (or everything) were locked,
directly or through a parent group. It now rejects those destinations
with the same error as Fill Path and ordinary painting, leaving the
layer unchanged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374)
Since #1517 every PSD save with type layers rebuilt the document's Txt2
block: it replaced everything under /1 with the text-object list, emptied
objects no layer names, and cut each kept object's style runs down to the
auto-kern mode alone. Photoshop then refused to open an unedited re-save
("Could not complete your request because of a program error").
build_txt2 now starts from the file's own block: the other /1 keys and
unnamed objects are kept, a kept object's style runs keep every style key
and only get /11 set (split where a mode changes inside a run), and a block
whose content comes out unchanged is returned byte for byte.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(psd): Photoshop type files keep Txt2 byte-for-byte on an unedited save (#2374)
Corpus test over corpus/photoshop/text: import, export, re-import, and
compare the Txt2 global block. All 56 Photoshop-authored type files pass;
the previous build_txt2 fails on the first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: vivekprojects-GIT <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Session::jobs_with_recent is documented as "running jobs followed by the
last few that ended", but chained the ended jobs first, so jobs.list and
MCP jobs_list returned a finished job ahead of a running one. Running
jobs now come first, as documented.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Border built a hard band r pixels wide (expand r/2 minus contract r/2).
Photoshop's Border is soft and twice as wide: measured on Photoshop
27.11 (scripted), Border 4 on a 32 px square gives 63, 127, 191, 255 |
255, 191, 127, 63 across the edge, and the same along the inside of a
corner; PhotoCraft gave 0, 0, 255, 255 | 255, 255, 0, 0.
A pixel D away (centre to centre) from the nearest pixel on the other
side of the edge is now selected 1 - (D - 1) / r, using the existing
exact distance transform. That matches every sampled pixel within 1.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Smooth box-averaged the selection and then applied a contrast curve
((v - 1/2) * 4 + 1/2). That left a band of half-selected pixels along
every edge: a straight edge of a 32 px square came out 158 instead of
255 after Smooth 8, and a fill showed a soft grey border.
Measured on Photoshop 27.11 (scripted): Smooth 8 on the same square
gives only 0 and 255; straight edges stay exactly in place and the
corner is cut where the 17x17 box average crosses 1/2. Smooth now
thresholds the box average at 1/2, which reproduces every sampled
pixel.
Co-authored-by: Claude Opus 5.5 <[email protected]>