Compare commits

..
Author SHA1 Message Date
Tanish Manku d9e1c10c30 Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines) 2026-10-15 15:22:09 -07:00
Tanish Manku 4a1acca879 Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute 2026-10-15 15:15:11 -07:00
Tanish Manku a95b3f0a85 Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied 2026-10-15 15:15:11 -07:00
Tanish Manku b7eab1a377 Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal 2026-10-15 15:15:11 -07:00
Tanish Manku 25a8200110 New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order 2026-10-15 15:15:10 -07:00
Tanish Manku 6443f5fb4c Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
2026-10-15 15:15:01 -07:00
skylar taylor-barrick af54f4aed1 ci: limit the font pass to font-consuming crates (#6127)
Keep the full workspace and feature build selection while running the craft-fonts pass only for text, io and ui-egui. Ordinary workspace test coverage remains in the first test pass.
2026-10-15 16:03:39 -06:00
skylar taylor-barrick 1a4167936e ci: run tests with cargo nextest (#6118)
Run workspace tests with pinned cargo-nextest, a CI fail-fast profile and a ten-minute per-test hang limit. Keep cheap checks first and doctests in a separate step so failures propagate on Windows.
2026-10-15 16:02:57 -06:00
Tanish Manku 54f4baf6f9 Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856)
* Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names

With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.

History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).

* Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes

* Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856)

* Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856)
2026-10-15 14:33:51 -07:00
Tanish Manku 3cd0f6c954 Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349)
* Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a)

A key that another command holds now offers Photoshop's two answers under the warning.
Accept and Go to Conflict takes the key from the other command at once (its main key or
an alternate) and selects it for a new one. Undo Changes puts the edited command's main and
further keys back as they were. OK without either still accepts, as in Photoshop.

Keys the system takes before the app sees them are refused with a message: Alt+Tab,
Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and
Cmd+Opt+Esc on it.

* Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text
2026-10-15 14:18:14 -07:00
Tanish Manku 04db697b5d Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) 2026-10-15 14:17:22 -07:00
Tanish Manku c11ef015b7 Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881)
* Paste Layer Style carries Blend If and channels; align to an active selection with several layers

From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
  dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
  Blending Options. Across colour modes only Blend If's Gray range carries over and the
  channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
  struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
  their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
  Selection), as it already did for one layer.

* Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests
2026-10-15 13:10:28 -07:00
Tanish Manku 77a258b9a0 Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868)
* Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles

From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
  Adobe documents ("removes the selected layer and any layers above it from the clipping
  mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
  layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
  the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
  3, as single Delete already does), instead of leaving them clipped to the layer below.

* Merge Layers keeps the result clipped only when the clipped layers share one clip chain

* fmt
2026-10-15 13:10:22 -07:00
e4b2868a02 Check deep EXR tile count tables against max_alloc (#5632)
* Check deep EXR tile count tables against max_alloc

A deep tile's sample-count table was decompressed at the header's full tile size with no
budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB
max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size.

Fixes #4958.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep deep EXR decompression within the size it was admitted at

The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling
gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table
and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected
size, inflating straight into it, and allocate it and the scratch copy fallibly.

Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a
clipped table decodes under every budget; per-tile admission; half and uint channels after
full-size tables; the 8 MiB boundary for uncompressed tables too.

Refs #4958.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Tests: show deep tile tables are admitted one tile at a time

The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check,
so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that
summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all
edge tiles, and under 4096 bytes a summed check fails at the third tile.

Refs #4958.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Complete deep EXR count-table allocation admission

Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible.

Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions.

* ci: run deep EXR regressions independently

* Drop the separate deep EXR CI job

It was added so these tests could run while main's workspace suite was
failing. Main is green again, and the regular test jobs run them.

This reverts commit 5aa4869b45.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
2026-10-15 13:40:50 -06:00
Michael HigginsandClaude Opus 5.5 64520f645e Control channel: connections must authenticate within 10 s, however active (#4965) (#5229)
The only limit on an unauthenticated control connection was the 30 s
idle timeout, which starts again with every read. A local client that
kept sending bytes or blank lines without ever completing `auth` kept
its connection, and one of the 16 connection permits, for as long as it
liked; sixteen of them locked every other client out.

AuthDeadlineStream wraps the read half of a connection: until the
connection authenticates, reads fail once AUTH_DEADLINE (10 s) has
passed since it connected, and a blocking read waits no longer than
what is left. Both transports use it (the desktop app's control server
and the headless `photocraft-cli serve`). After `auth` succeeds only
the idle timeout applies, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:39:19 -06:00
Michael HigginsandClaude Opus 5.5 b24c0360f1 Dependencies: require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#4952) (#5225)
ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate
several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft
and Affinity files, so such a file could end the process with an
out-of-memory abort.

Raise the minimum in both crates that use it and update the lockfile
from 0.9.0 to 0.9.1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:37:56 -06:00
Khawar Zamman WaniandClaude Opus 5.5 1b666f4790 Pen: Auto Add/Delete on the path being drawn (#6140) (#6172)
While a path was being drawn, the Pen's Auto Add/Delete was off, so adding
a point meant finishing the path and switching to Add Anchor Point. Now a
click on a segment of the path in progress adds an anchor, a click on a
middle anchor deletes it, and drawing carries on from the last anchor. The
first anchor still closes the path and the last still breaks its handle.

The hover highlight draws over the path in progress, and the rubber band
hides where a click adds or deletes instead of placing an anchor.


Claude-Session: https://claude.ai/code/session_01JFTxZ57wCmWEDXxCn1Zuni

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:12:35 -06:00
ZuujisandClaude Opus 5.5 99083fca4c Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095) (#6108)
* Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095)

Image › Adjustments on an alpha channel or the Quick Mask ran on the surface's
stored content only. The black outside a selection-made Quick Mask (or a new
black alpha channel) is the surface default, never stored, so Invert left it
black: the whole mask turned red and leaving Quick Mask selected nothing.
Channel targets now go through `pixels::adjust_mask`, as layer masks do, which
adjusts the default pixel too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Quick Mask: test ⌘I with Color Indicates Selected Areas too (#6095)

A rectangle comes back as everything but the rectangle, and with nothing
selected ⌘I selects everything. Both failed on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:11:42 -06:00
skylartaylor d68a1cbc2d ci: preserve separate nextest and doctest results 2026-10-15 19:56:43 +02:00
skylartaylor bcf2cefd4b ci: preserve nextest failures on Windows 2026-10-15 19:56:43 +02:00
skylartaylor 4f6e617555 ci: retain the font filter with integrated nextest checks 2026-10-15 19:44:21 +02:00
skylartaylor 40a7c20430 ci: integrate nextest with early checks and runtime limits 2026-10-15 19:44:21 +02:00
skylar taylor-barrick 5f9190f8cc ci: run cheap checks before tests (#6124)
Run the existing Clippy, dependency layering and scorecard checks before the test passes so failures are reported earlier.

Preserve the existing commands, test coverage and runtime limits. Linux, Windows, corpus, WASM, web and packaging checks passed.
2026-10-15 11:42:53 -06:00
skylar taylor-barrick d2fd1a012a ci: add comfortable runtime limits to unbounded jobs (#5931)
Add explicit runtime limits to eight previously unbounded CI, documentation and packaging jobs, preserving existing limits and quality gates.

Validated the timeout-only diff and completed Linux, Windows, corpus, WASM, web, documentation and packaging checks.
2026-10-15 11:26:52 -06:00
midasmatterhorn c06eca9b68 fix(psd): preserve PSB foreign-order block lengths
Choose PSB block length width from the canonical big-endian key before converting the key for little-endian TIFF output. Preserve foreign payloads, padding and conversion of following blocks.

Addresses #5599, finding 2 only.

Fixes #6104.
2026-10-15 11:23:57 -06:00
skylar taylor-barrick f9ed9e3e57 ci: refresh timeout draft against current main 2026-10-15 16:27:05 +02:00
skylar taylor-barrick 4e7a17cd5d fix(compose): serialize effect identity cache misses
Keep effect-identity memo lookup, pure hashing and insertion atomic across concurrent render callers. Preserve existing identities and add a contention regression with eight synchronized callers.
2026-10-15 08:18:21 -06:00
David MannandClaude Opus 5.5 caa28cb7ab CI: run only the font tests in the craft-fonts pass (#6120)
The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text,
photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning
above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:41:12 +02:00
David MannandClaude Opus 5.5 e22c4ec7ab CI: run the tests with cargo nextest (#6117)
cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci`
profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for
10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log;
the GPU test group still applies. Same --workspace --locked --features heif selection, so the build
is unchanged. Doctests stay on cargo test --doc.

postscript_only_style_shows_actual_subfamily relied on another test in the same process having
started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:40:15 +02:00
David MannandClaude Opus 5.5 9660dca263 CI: run clippy, layers and scorecard checks before the tests (#6119)
They take about 1.5 min together but ran after all the test passes, so a clippy failure (15 s)
showed red only 41-48 min into the job. Same steps, reordered: no cost on a green run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:22:13 +02:00
df055f3dcb CI: fix the tests that keep main red, and ship the wasm as compressed parts (#5926, #5302) (#5941)
* Fix two ui-egui tests broken on main by concurrent landings

- profile_open_tests: #5614 renamed the built-in profile to "(PhotoCraft)" after #5208
  hard-coded "(Photocraft)" in the Use-embedded button label.
- smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy (#5779): the reduced
  square is in the right place, but its corner pixels are 220/255 after the smart
  object's resampling; assert full coverage inside and over half at the corners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Web: ship the wasm as compressed parts behind a boot module (#5302)

The release site held the app's wasm as one file, which sits at the 25 MiB
per-file cap of Cloudflare Pages, so the 24 MiB gate failed unrelated PRs.

packaging/web/package.sh now gzips the wasm and cuts it into parts of at
most 20 MiB under app/, and ships the page of a new crate,
apps/photocraft-web-boot. That module fetches the parts in order, has the
browser inflate them (DecompressionStream) and passes the stream to the
app's own wasm-bindgen loader, so the browser still compiles while it
downloads. A failed download is retried twice, then the page says so.

The check is now that no file in the site is over 24 MiB. trunk build in
apps/photocraft-web and the Docker image still give one .wasm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Web boot: inflate the parts in the module where the browser can't (#5302)

* Tests: the adjustment-layer drag preview test follows #5778 (#5926)

#5778 caps a live adjustment-layer preview by the view's zoom, so at 100 %
the drag shows full resolution even with Low Resolution Previews on. The
test still expected the reduced copy there. It now checks that at 100 % every
column shows by default, and moves the low-res on/off comparison to 50 %,
where the reduced copy is still used: on, the drag shows only the (inverted)
black columns; off, it matches the committed view.

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]>
2026-10-15 09:21:30 +02:00
ad96df566b File > Save As / Save a Copy / CLI can write PDF (#3401) (#3441)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* PSD: a smart object's filter mask shows when the file opens

Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.

- compose::filter_mask holds the mix, moved from the engine, and its
  inverse.
- Import decodes the unfiltered pixels from FEid
  (smart_map::unfiltered_from_item) and keeps the mixed result as the
  smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
  the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
  back to what the document showed. Transparent pixels stay empty.

Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* File > Save As / Save a Copy / CLI can write PDF: one flattened page with the embedded profile (#3401)

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: timothydaly1 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 01:03:27 -04:00
4e1e325b17 PSD: a smart object's filter mask shows when the file opens (#5005)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* PSD: a smart object's filter mask shows when the file opens

Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.

- compose::filter_mask holds the mix, moved from the engine, and its
  inverse.
- Import decodes the unfiltered pixels from FEid
  (smart_map::unfiltered_from_item) and keeps the mixed result as the
  smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
  the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
  back to what the document showed. Transparent pixels stay empty.

Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:58:45 -04:00
8a6af66cf6 DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11) (#5112)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:54:55 -04:00
a7301f1db8 PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198) (#5207)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:51:37 -04:00
1a4f867958 TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab (#4949)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:48:20 -04:00
47cf5af9fd perf: keep effect-cache pins and History snapshots inside the memory budgets (#5336)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:44:55 -04:00
c7d9ade95b Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open (#4462)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:40:46 -04:00
cb8ed7620e Content-Aware Fill: no ghost edges or object colour around thin removed objects (#5290)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:36:50 -04:00
4446ec6d65 PSD: write tiff blocks without copies, inflate merged ZIP once (#5221)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:32:55 -04:00
5dafbeeafc Codecs: open Cineon (.cin) film frames read-only (#4691)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* cargo fmt the extension lists

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]>
2026-10-15 00:23:03 -04:00
Vikram BhaskaranandBrandon Thomas 7edc7aa03d Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) (#4323)
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-15 00:22:48 -04:00
17c85e4175 fix(ui): brush outline matches the stroke on Wayland at fractional scales (#5796)
* fix(ui): brush outline matches the stroke on Wayland at fractional scales

On Hyprland at 1.25 the brush outline was 1.25x too large and sat right of
and below where the brush painted, by more the larger the brush. The engine
places dabs exactly; the OS cursor bitmap was wrong. winit 0.30 sizes custom
cursor bitmaps on Wayland with a wp_viewport destination whenever the scale
isn't 1, and Hyprland (like Mutter before GNOME 48) ignores viewports on
cursor surfaces: it shows the bitmap one pixel per logical pixel with the
hotspot winit divided by the scale.

A client can't tell which compositors do this, so on Wayland at any scale
but 1 the brush outline and crosshairs are painted in-app, as on the web.
Other platforms and Wayland at scale 1 keep the low-latency OS bitmap.

Measured on Hyprland 0.56.2 at 1.25 (100 % zoom, 100 px brush): dab centre
(288.5, 560.5), diameter 97 px; outline before (303, 574.5), ~130 px;
after (288.5, 562), 107 px including its border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Merge main; cargo fmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 23:50:19 -04:00
96c683b25d Info panel: show guide coordinates while dragging outside the canvas (#5728) (#5768)
* Fix Info panel guide coordinates during ruler drags (storytold/photocraft#5728).

Show the dragged guide position on one axis while the pointer is outside the canvas, matching Photoshop.

Co-authored-by: Cursor <[email protected]>

* Merge main; fill GuideDrag's new press field in the test; cargo fmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:50:14 -04:00
FredApps be32a7c651 fix: commit type editing on Layers-panel selection (#5584) (#5612) 2026-10-14 23:50:08 -04:00
David MannandClaude Opus 5.5 8987980d16 Dialogs: a close box in the title bar cancels like Cancel (#5496) (#5505)
Dialogs drawn by the shared renderer had no close box: the only ways
out were Cancel/OK or Esc. As in Photoshop on Windows, the title band now
ends in a × (the icon button the floating panels close with) on Windows,
Linux and the web, none on macOS. A click on it joins Esc in the single
cancel path of `dialogs::show` (outcome = Cancel -> `cancel()`), so the
live preview goes and nothing reaches the document or history. The × is
left out of the title band's drag area, so a drag starting on it neither
moves nor closes the dialog; the rest of the band still drags.

Check Spelling (an untitled window) gets the same close box; it is Done,
as every change there is already made.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:50:03 -04:00
Valentin KleinandClaude Opus 5.5 94b1f0a4cb Warp: a thin layer stretched and bent keeps its curve on commit (#5793) (#5810)
* Warp: a thin layer stretched and bent keeps its curve on commit (#5793)

The mesh warp sized its triangle grid by the source rectangle only (one cell per 4 source px). A 3 px column scaled into a wide band and then warped got a single cell across, so the committed result was a straight, affine band while the preview showed the curve. The grid now also follows the destination length of the mapped rows and columns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Warp: module doc describes the grid sized on both sides of the map (#5793)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:58 -04:00
bazandClaude Opus 5.5 38b96f273a Move: Show Transform Controls frames every selected layer (#5917)
As in Photoshop, with several layers selected (Shift-click with Auto-Select,
or the Move marquee) Show Transform Controls draws one frame around all of
them (the Background excluded) and outlines every selected shape layer.
Before, the frame showed only the active layer, so the other selected layers
had no feedback on the canvas. The frame joins the boxes Free Transform would
open on each layer (transform_frame), and it hides while a corner radius
widget is dragged.

Free Transform works on one layer, so the shared frame has no handles and a
press on it doesn't start Free Transform; a drag inside still moves every
selected layer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:52 -04:00
WendelVolm 0c7308adc5 Snapping: snap a drawn marquee, Object Selection box or shape by its edges while Space repositions it (#5748) 2026-10-14 23:49:47 -04:00
Michael HigginsandClaude Opus 5.5 0c5709e9bd Free Transform: shapes and paths stay on whole pixels (#5751) (#5841)
* Free Transform: shapes and paths stay on whole pixels (#5751)

With Snap Vector Tools and Transforms to Pixel Grid on, only the pointer was
rounded, and only while a shape tool was active. Under the Move tool, or with
a grab offset or a proportional scale, the box landed between pixels (a
rectangle dragged to 540 px came out 539.6). An upright box on a shape layer
or path now keeps whole-pixel edges whatever the tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Format (cargo fmt)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:42 -04:00
Michael HigginsandClaude Opus 5.5 6729fa1e50 Layer Style: drag on the image to move a drop or inner shadow (#5701) (#5893)
As in Photoshop, with the Layer Style dialog on a Drop Shadow or Inner Shadow
page, dragging on the image moves the shadow: its offset follows the pointer
and is stored as the effect's angle and distance (whole degrees and pixels),
moving the shared global light angle when the effect uses it. Space and the
middle button still pan; colour pickers still sample.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:36 -04:00
Michael HigginsandClaude Opus 5.5 56d3b7f983 Pen and Path Selection: Delete deletes the finished work path (#5702) (#5732)
A finished work path is drawn whenever the Pen, Freeform Pen or Path
Selection tool is active, and it is saved with the document, as in
Photoshop. But once the path was finished, Delete/Backspace fell through
to Edit > Clear (the pixels under it), so the only ways to remove it were
the right-click Delete Path and the Paths panel, and it looked impossible
to delete.

With those tools, no path being drawn and the work path the one they
edit (no shape layer or targeted vector mask in front of it), an
unmodified Delete/Backspace now runs path.delete on the work path: one
undoable Delete Path step. Direct Selection and the Curvature Pen are
left alone, since Photoshop deletes their selected anchors, not the path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:30 -04:00
David MannandClaude Opus 5.5 8226139806 Image Size, Canvas Size: points and picas follow the Point/Pica Size preference (#5756) (#5757)
The dialogs converted points and picas at a fixed 72 pt/in, while the
rulers, Info, layer properties and the shape dialog use Preferences >
Units & Rulers > Point/Pica Size. With Traditional (72.27) a document
showed different point sizes in the rulers and in Image Size.

The dialog now stores the preference's points per inch when it opens
(__ptPerIn, 72 when missing) and uses it for points, picas (points / 12)
and typed units in the Width/Height fields.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:49:25 -04:00
David MannandClaude Opus 5.5 b404d50581 Type: Swap and Default Colors recolour the selected text (#5690) (#5808)
The toolbar's Switch Colors and Default Colors buttons ran their commands
without type_tool::foreground_changed, so characters highlighted in the
type layer being edited kept their colour. As in Photoshop, they now take
the new foreground, like picking a colour in the Color or Swatches panel.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:49:20 -04:00
envy-ai e47086717a Channels: load the targeted layer mask as selection (#5562)
* Load the targeted layer mask from Channels footer

* Fix Channels test setup after mask reveal
2026-10-14 23:49:15 -04:00
Vikram Bhaskaran f02c0cbef7 File › Scripts › Browse: reject Photoshop ExtendScript (#5406) (#5435)
* File › Scripts › Browse rejects Photoshop ExtendScript.

The picker listed .js/.jsx files that parse_script cannot run. Filter to
JSON/text/droplet scripts and return a clear error for .js/.jsx paths.

* File › Scripts › Browse keeps Photoshop extensions in the picker.

Review feedback on #5435: filtering .js/.jsx out of the picker hid the
rejection error from UI users. List Photoshop's script types (as #2452
asked) alongside PhotoCraft's so selecting one shows the ExtendScript
error instead of hiding the file.
2026-10-14 23:49:09 -04:00
safvanedgeandsafvanedge cc9967654c fix(ui): restore previous tool when committing or canceling text edit (#5527) (#5528)
* fix(ui): restore previous tool when committing or canceling text edit initiated via double-click

* fix(ui): restore return tool only while on type and stay on type for layer thumbs

- commit_with/cancel: restore return tool only while app.ui.tool.is_type()
- edit_active: keep tool on Type when editing from layer thumbnail (matches Photoshop)
- clear text_edit_return_tool when text_edit is dropped on lost text or stale transform
- test tool switch mid-edit and edit_active remaining on Type

---------

Co-authored-by: safvanedge <[email protected]>
2026-10-14 23:48:55 -04:00
David MannandClaude Opus 5.5 8d8db22c94 Type: a scaled type layer keeps its size when the Character fields show it (#5717) (#5718)
egui's DragValue clamps an existing value to its range and reports a change, so showing a
type layer scaled past 1296 pt (or below 0.1 pt) sent type.setStyle with the clamped size.
Value fields now clamp only edits, never a value they merely show.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:48:48 -04:00
Stevan MarjanovićandStevan Marjanović 49f878d91b Scaling around reference point no longer snaps center of image to reference point (#5510)
Co-authored-by: Stevan Marjanović <[email protected]>
2026-10-14 23:48:43 -04:00
Michael HigginsandClaude Opus 5.5 3fd787a2b5 Canvas: no scroll glide with Enable Animations off (#5721) (#5883)
Interface > Enable Animations off already made scroll areas jump straight
to their target, but the canvas still panned from egui's smoothed wheel
delta, gliding for several frames after each notch. With the preference off
a plain wheel scroll now moves the canvas by the whole notch in the frame it
arrives and stops there.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:37 -04:00
midasmatterhorn f79f21931e fix(ui): smart filter adjustments open their own editor on double-click (#5812)
An Image > Adjustments recorded on a smart object (Brightness/Contrast,
Levels, Curves...) stores image.adjustments.<kind>. The Layers panel row
called smart_ui::open_editor, which only knew filter_dialog and silently did
nothing for those commands, so the filter could never be reopened. Route
adjustments to adjust_dialog, seed it with the recorded params and let OK run
layer.smartFilter.setParams (#5689).
2026-10-14 23:48:32 -04:00
Michael HigginsandClaude Opus 5.5 f44d3b6f06 Brushes: dragging a preset to the list's edge scrolls the list (#5741) (#5862)
A preset, group or folder dragged in the Brushes panel or the Brush Preset
picker couldn't reach rows out of view: the list didn't scroll while
dragging, so the drag stopped at the last visible row. Holding the drag at
the list's top or bottom edge (or past it) now scrolls it, with the same
ramp as the Layers panel's row drags.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:26 -04:00
Michael HigginsandClaude Opus 5.5 b8e48d6d4c Filter dialogs: a Random button for seeds (#5710) (#5886)
Clouds, Difference Clouds, Add Noise, Fibers and the other seeded filters
exposed a seed number, but a new pattern meant typing a different one. A
Random button beside every seed field now picks a fresh seed, which the live
preview renders at once. "Random" gains its row in the zh-Hans, zh-Hant, ru,
fr and id catalogs, which claim complete coverage.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:20 -04:00
fd28da56da Type: keep text selection highlight visible while Command/Ctrl is held (#5542)
* Type: keep text selection highlight visible while Command/Ctrl is held

* Type: match Photoshop 1px dark frame and handle styling for type transform

* Merge main; keep the transform box and underline colours from the theme tokens

The selection-highlight fix stands on its own; the hard-coded box and
underline colours and the removed reference point were unrelated to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: safvanedge <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:46 -04:00
7efebfa050 fix(path): Shift locks a Direct Selection anchor drag to 45° (#5475) (#5498)
Dragging selected anchors with the Direct Selection tool ignored Shift,
so lengthening a slanted bar also nudged it vertically. As in Photoshop,
Shift now locks the anchor move to multiples of 45° from the press, in
the live preview and in the committed path.moveAnchors. It works whether
Shift is held from the press or added during the drag; Shift at the
press still toggles the anchor's selection as before.

Reuses stroke_constraint::snap45 (the Gradient tool's Shift lock).

Fixes #5475

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 22:46:41 -04:00
Michael HigginsandClaude Opus 5.5 3887d8f738 Title bar: dialogs no longer stop the window dragging on macOS (#5826) (#5860)
The overlay that keeps the title strip's free gap draggable under a dialog's
modal layer (#4913) only ran with the custom title bar. macOS keeps the OS
traffic lights over the app's own title strip (custom_titlebar is false), so
with the Embedded Profile Mismatch prompt or any dialog open the window could
not be moved. The overlay now runs whenever the strip is drawn.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:36 -04:00
Michael HigginsandClaude Opus 5.5 8e2c5ddb40 macOS: a bundled app keeps its own Dock icon (#5849) (#5879)
The main window set a runtime PNG icon everywhere. On macOS that replaces the
app bundle's PhotoCraft.icns in the Dock, so the system couldn't render it
in its own style (Liquid Glass and its tinted and dark variants). The runtime
icon is now skipped when running from an .app bundle; unbundled runs and the
Windows and Linux builds still set it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:30 -04:00
nrq6993-a11yandCursor b6cdb16caf UI: unsaved-changes buttons show every key in one style (storytold/photocraft#5564) (#5603)
German showed "Ja (Y)" next to "(N)ein". Keys now go inside the words only when
every translated label starts with its key; otherwise all labels append it
("Ja (Y)", "Nein (N)", "Abbrechen (C)").

Co-authored-by: Cursor <[email protected]>
2026-10-14 22:46:25 -04:00
Michael HigginsandClaude Opus 5.5 3aaffdde68 Adjustment layers: a live preview is never coarser than the screen (#5764) (#5778)
Dragging a Curves point (or any control) in the Properties panel of an
adjustment layer previews through `canvas::ensure_proxy_preview`, which
picked its downscale factor from the document size alone
(`proxy::preview_factor`): any document over 5 MP was drawn from a 2-5x
reduced copy at every zoom, so at 100 % the canvas turned blocky until
the mouse was released.

Cap the factor by the view's zoom with the rule the Image > Adjustments
dialogs already use (`adjust_preview::proxy_factor`: the proxy stays at
least at screen resolution) when the wgpu compositor can draw the
full-size document. At 50 % and closer the drag now shows the full-size
composite; zoomed out, the proxy still saves the work. Documents the GPU
compositor doesn't cover keep the old proxy (the full size would be a
CPU composite per change).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:20 -04:00
Michael HigginsandClaude Opus 5.5 be4cadd83d Rulers: Alt-drag makes a guide of the other orientation (#5698) (#5885)
As in Photoshop, dragging a guide out of the top ruler with Alt/Option held
makes a vertical guide, and out of the left ruler a horizontal one; without
Alt each ruler makes its own kind, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:15 -04:00
FredApps 37f57f2e9c Preserve Layers scroll per document (#5522) (#5540)
* chore: reserve draft implementation for #5522

* fix(ui): retain each document's Layers scroll position (#5522)
2026-10-14 22:46:09 -04:00
Valentin KleinandClaude Opus 5.5 2fbd171427 Options bar: Show Transform Controls in every theme (#5740) (#5802)
The Studio, Adwaita and Breeze themes' Move tool options bar had only Auto-Select and a hint, so Show Transform Controls could not be turned on there and a click on a layer never showed its box and handles. The toggle is now in every theme, as Auto-Select is (#1275).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:04 -04:00
Michael HigginsandClaude Opus 5.5 f4351cb37c Drag and drop: PhotoCraft comes to the front after a drop (#5687) (#5894)
Files dropped from another app's window (a file manager) were imported, but
the source window stayed in front of PhotoCraft. The frame that takes a drop
now asks for focus, so PhotoCraft comes forward as Photoshop does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:45:59 -04:00
Nigel Ihza Firdausy 9b59a8ca6e fix(pen): keep unfinished paths in their original document (#5460)
* fix(pen): finish draft before creating a document

* test(pen): keep open work path in its source document
2026-10-14 22:45:45 -04:00
midasmatterhorn 88c7286d99 fix(ui): the Move tool keeps Photoshop's arrow pointer (#5814)
The canvas cursor match mapped Tool::Move to CursorIcon::Move, the 4-way
move cursor, while Photoshop shows its standard arrow pointer and marks the
movable layer with the hover outline. Use CursorIcon::Default for the tool
(#5646).
2026-10-14 22:45:39 -04:00
Stevan MarjanovićandStevan Marjanović 2b934aa53a UI: centre text vertically in shared text fields (#5550)
* UI: centre text vertically in shared text fields

TextEdit draws its line at the top of its rect, and the theme's 24 pt
interact height made the edit taller than the field's inside, so text in
widgets::text_field sat a few pixels high (Preferences search, hex fields,
name fields) while dropdowns and value fields were centred. Fill the box
and centre the line in it.

* Fixed failing test

---------

Co-authored-by: Stevan Marjanović <[email protected]>
2026-10-14 22:45:34 -04:00
Bihacsy László 414ae0b2c2 i18n(hu): align six rows with the catalog conventions (#5434)
Three prompts used the formal verb form ("Adjon", "Válasszon",
"kattintson"); the catalog addresses the user informally throughout.

Two colour profile messages put an article before a placeholder
("A(z) „{name}”") and glued a suffix to one ("{mode}-profilja"). The
correct form depends on the inserted value, so the placeholders now
stand after a colon or in brackets.

The Ask On Mismatch hint kept the English option name; it now names
the Hungarian label of that option in Color Settings.
2026-10-14 22:45:29 -04:00
skylartaylor b2fafcf68c ci: add comfortable runtime limits to unbounded jobs 2026-10-15 04:27:54 +02:00
FredAppsandFredApps cb319fd742 Document layerFilter in the ui_set MCP schema (#5776)
Co-authored-by: FredApps <[email protected]>
2026-10-14 21:54:48 -04:00
Tanish Manku 7bb10a7759 style: rustfmt five files on main (fmt break from #5448, #5670, #5135) (#5873)
Formatting only; cargo fmt --all -- --check passes again.
2026-10-14 16:11:18 -07:00
191 changed files with 8816 additions and 882 deletions

No files matched your search

+10 -1
View File
@@ -1,5 +1,6 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
# keeps working (#2204).
#
# cargo nextest run --workspace # all tests except doctests
# cargo test --doc --workspace # nextest does not run doctests
@@ -13,3 +14,11 @@ gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
+62 -36
View File
@@ -39,6 +39,7 @@ jobs:
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
@@ -65,7 +66,7 @@ jobs:
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
# packages changed: the web app and its packaging, crate sources and manifests (the web
# build compiles nearly every crate), the workspace manifests and this workflow.
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
echo "web=true" >> "$GITHUB_OUTPUT"
else
echo "web=false" >> "$GITHUB_OUTPUT"
@@ -77,6 +78,7 @@ jobs:
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }})
needs: platforms
runs-on: ${{ matrix.os }}
@@ -111,40 +113,12 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
- name: Test
run: cargo test --workspace --locked --features heif
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace --locked --features heif
- name: Clippy
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
@@ -157,7 +131,53 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
# Separate native commands into steps so Windows reports either command's failure.
- name: Test
run: cargo nextest run --workspace --locked --features heif --profile ci
- name: Doctests
run: cargo test --doc --workspace --locked --features heif
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
# not what is built); engine and the apps only pass text on to them (#6120).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --workspace --locked --features heif --profile ci
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
@@ -190,6 +210,7 @@ jobs:
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
@@ -206,6 +227,7 @@ jobs:
run: cargo xtask wasm
web:
timeout-minutes: 60
name: web build and wasm size gate
# PRs run it only when web-relevant files change (see `platforms`); main always does.
needs: platforms
@@ -229,8 +251,8 @@ jobs:
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
# wasm limit and exercises the same packaging step used by release.yml.
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
- name: Build and verify browser release
run: packaging/web/package.sh
- name: Report wasm size
@@ -241,15 +263,19 @@ jobs:
echo
echo "| File | Bytes | MiB | Limit |"
echo "| --- | ---: | ---: | ---: |"
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
bytes=$(wc -c < "$wasm" | tr -d ' ')
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
done
# What a host sees: the largest file of the packaged site.
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
timeout-minutes: 60
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
+1
View File
@@ -27,6 +27,7 @@ concurrency:
jobs:
build:
timeout-minutes: 15
name: mdBook build
runs-on: ubuntu-latest
steps:
+1
View File
@@ -23,6 +23,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
timeout-minutes: 10
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
+1
View File
@@ -39,6 +39,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
Generated
+13 -2
View File
@@ -5073,6 +5073,17 @@ dependencies = [
"web-sys",
]
[[package]]
name = "photocraft-web-boot"
version = "0.9.0"
dependencies = [
"flate2",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "pico-args"
version = "0.5.0"
@@ -6102,9 +6113,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "photocraft-web-boot"
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "photocraft-web-boot"
path = "src/main.rs"
[dependencies]
# Inflates the parts in browsers without `DecompressionStream`.
flate2 = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+6
View File
@@ -0,0 +1,6 @@
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
[build]
target = "index.html"
dist = "../../dist/web-boot"
public_url = "./"
filehash = true
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
</style>
</head>
<body>
<canvas id="photocraft_canvas"></canvas>
<div id="photocraft_loading">
<p>Loading PhotoCraft&hellip;</p>
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
</body>
</html>
+356
View File
@@ -0,0 +1,356 @@
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
//!
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
//! loader, so the browser still compiles while downloading. No file on the host is larger than
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
//! In one that can't, this module inflates them itself, a little slower.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
//!
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
//! for hosts without a per-file cap; only the packaged site starts through this module.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
/// without this an HTML page would be read as a list of parts.
#[cfg(any(target_arch = "wasm32", test))]
const MARKER: &str = "photocraft-parts 1";
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
/// file next to `parts.txt`: anything that could point elsewhere is refused.
#[cfg(any(target_arch = "wasm32", test))]
fn parts(list: &str) -> Option<Vec<String>> {
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
if lines.next() != Some(MARKER) {
return None;
}
let parts: Vec<String> = lines.map(str::to_owned).collect();
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
#[cfg(any(target_arch = "wasm32", test))]
impl Inflater {
fn new() -> Self {
Inflater(flate2::write::GzDecoder::new(Vec::new()))
}
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
/// to input until it has a whole block).
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
std::io::Write::write_all(&mut self.0, chunk)?;
Ok(std::mem::take(self.0.get_mut()))
}
/// The rest, once the stream has ended. An error when it ended early or its checksum is
/// wrong, the same as the browser's inflater gives.
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
self.0.try_finish()?;
Ok(std::mem::take(self.0.get_mut()))
}
}
#[cfg(target_arch = "wasm32")]
mod boot {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
};
use super::Inflater;
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
/// Seconds to wait before each further try; one more than this many tries are made.
const RETRY_AFTER: [u32; 2] = [1, 3];
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
// name (Trunk's own name for it carries a content hash).
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
extern "C" {
#[wasm_bindgen(js_name = default, catch)]
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
}
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
// have to be set for the whole build, so the three members used are declared here.
#[wasm_bindgen]
extern "C" {
type DecompressionStream;
#[wasm_bindgen(constructor, catch)]
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
#[wasm_bindgen(method, getter)]
fn readable(this: &DecompressionStream) -> ReadableStream;
#[wasm_bindgen(method, getter)]
fn writable(this: &DecompressionStream) -> WritableStream;
}
fn window() -> Result<web_sys::Window, JsValue> {
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
}
/// `fresh` skips the browser's cache.
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
let init = RequestInit::new();
if fresh {
init.set_cache(RequestCache::Reload);
}
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
if !response.ok() {
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
}
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
}
Ok(response)
}
/// Copies each part into `sink` in order, then closes it.
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
let keep_open = StreamPipeOptions::new();
keep_open.set_prevent_close(true);
for part in &parts {
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
}
JsFuture::from(sink.close()).await?;
Ok(())
}
/// Passes inflated bytes on to the stream's reader.
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
}
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
fn own_inflater() -> Result<TransformStream, JsValue> {
let inflater = Rc::new(RefCell::new(Inflater::new()));
let last = inflater.clone();
let busy = || JsValue::from_str("the inflater is in use");
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
move |chunk: JsValue, out: TransformStreamDefaultController| {
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
},
);
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
});
let transformer = Transformer::new();
transformer.set_transform(&transform.into_js_value().unchecked_into());
transformer.set_flush(&flush.into_js_value().unchecked_into());
TransformStream::new_with_transformer(&transformer)
}
/// `compressed` inflated: by the browser where it can, otherwise here.
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
let inflater = DecompressionStream::new("gzip")?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
} else {
let inflater = own_inflater()?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
};
Ok(compressed.pipe_through(&pair))
}
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
let sink = joined.writable();
spawn_local(async move {
if let Err(e) = feed(parts, sink.clone(), fresh).await {
let _ = sink.abort_with_reason(&e);
failed.replace(Some(e));
}
});
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
init.set_headers(&headers);
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
let arg = js_sys::Object::new();
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
match start_app(arg.into()).await {
Ok(_) => Ok(()),
Err(e) => Err(cause.take().unwrap_or(e)),
}
}
async fn sleep(seconds: u32) {
let wait = js_sys::Promise::new(&mut |done, _| {
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
let _ = done.call0(&JsValue::NULL);
}
});
let _ = JsFuture::from(wait).await;
}
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
fn say(text: &str, reload: bool) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
loading.set_text_content(None);
let Ok(line) = document.create_element("p") else { return };
line.set_text_content(Some(text));
if reload && let Ok(link) = document.create_element("a") {
link.set_text_content(Some("Reload"));
let _ = link.set_attribute("href", "");
let _ = line.append_child(&link);
}
let _ = loading.append_child(&line);
}
pub fn start() {
spawn_local(async {
let mut attempt = 0;
loop {
let error = match run(attempt > 0).await {
Ok(()) => return,
Err(e) => e,
};
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
let Some(&wait) = RETRY_AFTER.get(attempt) else {
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
return;
};
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
sleep(wait).await;
attempt += 1;
}
});
}
}
#[cfg(target_arch = "wasm32")]
fn main() {
boot::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
}
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut z, bytes).expect("gzip");
z.finish().expect("gzip")
}
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
fn sample() -> Vec<u8> {
let mut x = 0x2545_f491_4f6c_dd1d_u64;
(0..200_000)
.map(|i| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
})
.collect()
}
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
/// they are cut.
#[test]
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
let (plain, packed) = (sample(), gzip(&sample()));
for cut in [1, 7, 4096, 65_536, packed.len()] {
let mut inflater = Inflater::new();
let mut out = Vec::new();
for chunk in packed.chunks(cut) {
out.extend(inflater.push(chunk).expect("push"));
}
out.extend(inflater.finish().expect("finish"));
assert_eq!(out, plain, "chunks of {cut}");
}
}
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
/// then tries again), never start a damaged app.
#[test]
fn the_inflater_refuses_a_short_or_damaged_stream() {
let packed = gzip(&sample());
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
let mut inflater = Inflater::new();
let mut out = inflater.push(bytes)?;
out.extend(inflater.finish()?);
Ok(out)
};
assert!(run(&packed).is_ok());
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
let mut damaged = packed.clone();
let middle = damaged.len() / 2;
damaged[middle] ^= 0x55;
assert!(run(&damaged).is_err(), "a changed byte");
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
assert!(run(b"").is_err(), "nothing at all");
}
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
}
/// A host that answers a missing file with its home page must not have that page read as a
/// list of parts.
#[test]
fn a_web_page_is_not_a_parts_list() {
assert_eq!(parts(include_str!("../index.html")), None);
assert_eq!(parts(""), None);
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
}
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
/// origin, refuses the whole list.
#[test]
fn a_part_name_cannot_point_outside_the_app_folder() {
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
}
}
/// The packaged site shows this crate's page, the development build the app's own. They are
/// the same page apart from which program the `<link data-trunk>` line builds.
#[test]
fn the_page_is_the_apps_page() {
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
assert_eq!(trunk(boot), trunk(app));
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
}
}
+2 -2
View File
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
+35
View File
@@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData {
}
}
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
/// replaced it with a flat picture (#5849). `exe` is the running executable.
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
let macos = exe.parent();
let contents = macos.and_then(std::path::Path::parent);
let bundle = contents.and_then(std::path::Path::parent);
name(macos).as_deref() == Some("MacOS")
&& name(contents).as_deref() == Some("Contents")
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
}
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
@@ -45,6 +64,22 @@ mod tests {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
/// Linux and Windows builds) still set the runtime icon.
#[test]
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
use std::path::Path;
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
assert!(!in_app_bundle(Path::new("photocraft")));
assert!(sets_runtime_icon(None));
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+41 -4
View File
@@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Instant;
use std::time::{Duration, Instant};
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
@@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(read);
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok && reader.get_mut().authenticated().is_err() {
break;
}
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
@@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
use std::time::Duration;
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
started.elapsed()
});
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
// the idle timeout.
let mut stream = TcpStream::connect(addr).unwrap();
for i in 0..150 {
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
if stream.write_all(byte).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let served = server.join().unwrap();
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
assert!(rx.try_recv().is_err(), "nothing was dispatched");
}
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
+4 -2
View File
@@ -78,9 +78,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default();
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
viewport: viewport
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
+5 -3
View File
@@ -21,8 +21,8 @@ mod recovery_tests;
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -53,6 +53,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PSD Document", &["psd", "psb"]),
("PDF", &["pdf"]),
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
@@ -725,7 +726,8 @@ mod tests {
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
let layered = ["psd", "psb", "pcraft", "ora"];
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
+1 -1
View File
@@ -16,7 +16,7 @@ synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9"
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+154 -1
View File
@@ -474,8 +474,10 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
let mut cost_fg = vec![0.0f32; sw * sh];
let mut cost_bg = vec![0.0f32; sw * sh];
let mut fixed = vec![FREE; sw * sh];
// Always the hole's own edge, and around a thin selection each part's surroundings.
let keep = thin_band(sw, sh, &shole);
for i in 0..sw * sh {
if shole[i] || dist[i] <= 1.5 {
if shole[i] || dist[i] <= keep {
fixed[i] = HARD_FG;
continue;
}
@@ -508,6 +510,22 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
Ok(out)
}
/// How far around a thin selection (strings, wires, and any small parts selected with them, all
/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of
/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the
/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection.
fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 {
let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::<Vec<_>>(), w, h);
let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32;
if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 }
}
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0
/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a
@@ -751,6 +769,31 @@ mod tests {
}
}
#[test]
fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() {
// A long thin selection across a grey ramp, and a small knob selected with it in a red
// corner. Each part keeps its own surroundings; a compact selection is unchanged.
let (w, h) = (900, 600);
let tex = noise(w, h, 4);
let img: Vec<f32> = (0..w * h * 3)
.map(|i| {
let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3);
let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 };
base + (tex[i] - 0.5) * 0.04
})
.collect();
let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w));
let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0;
let hole: Vec<bool> = (0..w * h).map(|i| line(i) || knob(i)).collect();
let area = auto_sampling(w, h, 3, &img, &hole);
let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0);
let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i])));
assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob");
assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole));
let disc: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect();
assert_eq!(thin_band(w, h, &disc), 1.5);
}
#[test]
fn without_colour_adaptation_the_fill_copies_pixels_exactly() {
// A smooth texture with fine noise, so that every value is unique: a filled pixel equal to
@@ -798,6 +841,116 @@ mod tests {
assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10));
}
/// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright
/// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an
/// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give
/// (the background and the straighter strings, which stay); and the selection a user would draw
/// around the coils, `margin` px wide.
fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec<f32>, Vec<f32>, Vec<bool>) {
let fine = noise(w, h, 11);
// Out-of-focus colour: broad blobs over two gradients.
let blob = noise(3, 3, 5);
let bg: Vec<f32> = (0..w * h * 3)
.map(|i| {
let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3);
let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c];
for b in blob.as_chunks::<3>().0 {
let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]);
v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp();
}
v + (fine[i] - 0.5) * 0.03
})
.collect();
// Signed distance to each object's surface, stamped from points along it.
let stamp = |sd: &mut [f32], pts: &mut dyn Iterator<Item = (f32, f32)>, half: f32| {
for (px, py) in pts {
let reach = (half + 4.0) as i32 + 1;
for dy in -reach..=reach {
for dx in -reach..=reach {
let (x, y) = (px as i32 + dx, py as i32 + dy);
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
let i = y as usize * w + x as usize;
sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half);
}
}
}
}
};
let mut coils = vec![f32::INFINITY; w * h];
for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() {
let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32);
let mut pts = (0..4000).map(|j| {
let t = j as f32 / 4000.0;
let a = 8.0 * std::f32::consts::TAU * t;
(18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin())
});
stamp(&mut coils, &mut pts, half);
}
let mut straight = vec![f32::INFINITY; w * h];
for y in [0.07, 0.93] {
stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0);
}
let draw = |base: &[f32], sd: &[f32]| -> Vec<f32> {
let mut img = base.to_vec();
for i in 0..w * h {
let cover = (0.5 - sd[i]).clamp(0.0, 1.0);
let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 };
for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() {
let v = &mut img[i * 3 + c];
*v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover);
}
}
img
};
let clean = draw(&bg, &straight);
let both: Vec<f32> = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect();
let img = draw(&bg, &both);
let hole = coils.iter().map(|d| *d <= margin).collect();
(img, clean, hole)
}
/// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise
/// cancels out, a ghost of the removed outline or a blotch of another shade does not.
fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 {
let (mut sum, mut n) = (0.0f64, 0usize);
for y in 3..h - 3 {
for x in 3..w - 3 {
if !hole[y * w + x] {
continue;
}
let mut e = [0.0f32; 3];
for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) {
for (c, ec) in e.iter_mut().enumerate() {
*ec += out[j * 3 + c] - clean[j * 3 + c];
}
}
sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::<f32>() / 3.0);
n += 1;
}
}
((sum / n.max(1) as f64).sqrt() * 255.0) as f32
}
/// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the
/// fill is the background, with no ghost of their outline and nothing of their colour copied
/// back in. Their selection is long but only a few pixels thick, so the fill is worked out at
/// a scale where it is still a few pixels thick rather than one where the strings' surroundings
/// have merged into a blur.
#[test]
fn thin_objects_leave_no_ghost_and_none_of_their_colour() {
let (w, h) = (360, 270);
let (img, clean, hole) = strings_scene(w, h, 2.0);
let source = auto_sampling(w, h, 3, &img, &hole);
let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default());
let blotch = blotch_error(w, h, &out, &clean, &hole);
let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0;
let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count();
// Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and
// 1121 pixels took the strings' colour.
assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255");
assert_eq!(bright, 0, "filled pixels with the strings' colour");
}
#[test]
fn variant_geometry() {
let img = vec![0.5f32; 10 * 6];
+100 -4
View File
@@ -16,6 +16,15 @@
//! source (the usual case), the vote copies the source pixels exactly; only along the seams
//! between copied pieces does it blend.
//!
//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is
//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about
//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive
//! search stays affordable; otherwise a membrane across the hole does, which over so short a
//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across,
//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to
//! look like the blur (a dark blotch, or a piece of another string), and the finer levels,
//! which only refine those matches, keep them: the removed object's outline stays visible.
//!
//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4
//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel
//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour
@@ -278,6 +287,38 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize,
b
}
/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a
/// string, a hair, a long brush stroke): see the module docs.
const THIN_RATIO: usize = 4;
/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance
/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long
/// its bounds).
fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize {
// The bounds and a ring of non-hole cells around them (also past the grid's edge).
let (gw, gh) = (hb.width() + 2, hb.height() + 2);
let outside: Vec<bool> = par::map(gw * gh, gw * gh >= PAR_MIN, |i| {
let (gx, gy) = (i % gw, i / gw);
if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() {
return true;
}
px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole)
});
let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max);
(2.0 * deepest).ceil() as usize
}
/// Most target pixels × sources the onion peel of a thin hole may compare (its search is
/// exhaustive, though most comparisons stop early: about a second at this many).
const PEEL_BUDGET: usize = 1 << 28;
/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources`
/// source centres stays within [`PEEL_BUDGET`].
fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum();
holes.saturating_mul(sources) <= PEEL_BUDGET
}
/// Bounds of the hole pixels of a role grid.
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
// Per row the first and last hole column; the union of the rows' spans.
@@ -1575,14 +1616,20 @@ fn complete_cow<'a>(
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
// structure where the texture has averaged out, and fills flat).
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
// is still about two patches thick: through it, not along it, is what counts (#1366).
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
let mut boxes = vec![hb0];
let mut ext = hb0.width().max(hb0.height());
let thickness = hole_thickness(w, hb0, px);
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
if thin {
ext = thickness;
}
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
@@ -1638,9 +1685,21 @@ fn complete_cow<'a>(
}
}
}
None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
// A thin hole, when peeling it would take too long, takes the membrane below: a few
// patches thick here, a membrane across it is its surroundings.
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
}
None => {
let unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
// Across a thin hole the membrane starts a patch radius out, past the
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
if thin {
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
*u |= d <= r as f32;
}
}
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
for (i, k) in lpx.iter().enumerate() {
if *k == Px::Hole {
@@ -2114,6 +2173,43 @@ mod tests {
.collect()
}
#[test]
fn a_hole_is_as_thick_as_it_is_through_not_along() {
let (w, h) = (240, 120);
let all = Bounds { x0: 0, y0: 0, x1: w, y1: h };
let thickness = |hole: &[bool]| {
let px: Vec<Px> = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect();
hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px)
};
// A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge
// counts the outside of the image as not hole.
let band: Vec<bool> = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect();
assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band));
assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0))));
let edge: Vec<bool> = (0..w * h).map(|i| i / w < 9).collect();
assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge));
}
/// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is
/// filled, only it changes, and the result does not depend on the thread count either.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn thin_holes_are_filled_the_same_on_any_number_of_threads() {
let (w, h) = (300, 200);
let img = busy(w, h, 3, 5);
// A wavy line 6 px thick across the image.
let hole: Vec<bool> = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect();
let px = roles(w, h, &hole);
let run = |threads: usize| {
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()
};
let one = run(1);
assert_eq!(bits(&one), bits(&run(7)));
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits())));
assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite())));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn results_do_not_depend_on_the_thread_count() {
+45
View File
@@ -448,6 +448,51 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32)
out
}
/// Fills the holes of `mask` (4-connected background areas not touching the border) by what
/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up
/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on
/// average, so an enclosed patch of backdrop stays open.
pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec<bool> {
let (w, h) = (img.w, img.h);
let mut out = mask.to_vec();
let area = out.iter().filter(|v| **v).count();
let max_speck = (area as f32 * speck_frac) as usize;
let max_hole = (area as f32 * hole_frac) as usize;
let inv: Vec<bool> = out.iter().map(|v| !v).collect();
let (hl, hs) = components4(&inv, w, h);
let mut touches = vec![false; hs.len()];
for x in 0..w {
touches[hl[x] as usize] = true;
touches[hl[(h - 1) * w + x] as usize] = true;
}
for y in 0..h {
touches[hl[y * w] as usize] = true;
touches[hl[y * w + w - 1] as usize] = true;
}
// Summed data costs per hole; only holes small enough to be candidates are evaluated.
let mut cost = vec![(0.0f64, 0.0f64); hs.len()];
for (i, l) in hl.iter().enumerate() {
let l = *l as usize;
if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck {
continue;
}
if let Some(p) = img.px.get(i) {
cost[l].0 += f64::from(fg.neg_log(*p));
cost[l].1 += f64::from(bg.neg_log(*p));
}
}
for (o, l) in out.iter_mut().zip(&hl) {
let l = *l as usize;
if l == 0 || touches[l] || hs[l] > max_hole {
continue;
}
if hs[l] <= max_speck || cost[l].0 <= cost[l].1 {
*o = true;
}
}
out
}
fn components4(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
let mut lab = vec![0u32; w * h];
let mut sizes = vec![0usize];
+65 -3
View File
@@ -13,8 +13,17 @@
//! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the
//! lightly blurred CIELAB image from its mean colour.
//! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels
//! are definite background); the result is cleaned (small islands dropped, small holes
//! filled) and re-cut at full resolution.
//! are definite background); the result is cleaned (small islands dropped).
//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted
//! to the image's frame marks every pixel it explains as probable background, and the rest
//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground.
//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white
//! wall) have low saliency and in step 4 land in the background model, which then explains
//! them away; here they start as foreground and the cut keeps them (#2788). Only what
//! touches the first pass's result survives, so the pass cannot pull in unrelated things.
//! 6. Holes are filled only when their colour fits the foreground model better than the
//! background's, so a patch of backdrop enclosed by the subject (between an arm and the
//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution.
//!
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
//! segmenter.
@@ -25,6 +34,7 @@ use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::Gmm;
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
use super::{Region, Sampler};
@@ -72,13 +82,65 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
}
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
let low = super::clean_mask(&low, w, h, 0.2, 0.0);
if !low.iter().any(|v| *v) {
return Ok(None);
}
let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg));
ctl.check()?;
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05);
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model
/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the
/// subject runs out of the picture); the frame is definite background, every other pixel it
/// explains well is probable background, and the rest (the blob, and anything the backdrop
/// cannot account for, such as a cream shirt on a near-white wall) starts as probable
/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that
/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass.
fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result<Option<(Vec<bool>, Gmm, Gmm)>, Cancelled> {
let (w, h) = (img.w, img.h);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, on) in blob.iter().enumerate() {
if *on {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
if x1 < x0 || y1 < y0 {
return Ok(None);
}
let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4);
let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin));
let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1));
let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2);
let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h;
let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y);
let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect();
let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) };
// What the frame itself costs under its own model bounds "explained well".
let mut frame_costs: Vec<f32> = samples.iter().map(|p| backdrop.neg_log(*p)).collect();
frame_costs.sort_by(|a, b| a.total_cmp(b));
let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) };
let mut trimap: Vec<u8> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
if in_frame(x, y) && !near_blob(x, y) {
BG
} else if blob[i] || backdrop.neg_log(img.px[i]) > limit {
PR_FG
} else {
PR_BG
}
})
.collect();
let Some((fg, bg)) = grabcut::grabcut_with(img, &mut trimap, 4, ctl)? else { return Ok(None) };
let cut: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::keep_seeded(&cut, blob, w, h);
Ok(low.iter().any(|v| *v).then_some((low, fg, bg)))
}
/// Per-pixel saliency in `0..=1` (see the module docs).
pub fn saliency(img: &super::RgbImage) -> Vec<f32> {
let (w, h) = (img.w, img.h);
+151
View File
@@ -761,3 +761,154 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
// ---------- Select Subject on the cases reported in #2788 ----------
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
/// backdrop): the whole figure is the subject. Returns (image, truth, the low-contrast part).
pub(crate) fn low_contrast_figure(seed: u64) -> (RgbImage, Vec<bool>, Vec<bool>) {
let (w, h) = (240usize, 200usize);
let mut rng = Rng::new(seed);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut cream = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let body = (90..150).contains(&x) && (70..190).contains(&y);
let shoulders = (80..160).contains(&x) && (40..70).contains(&y);
let print = (105..135).contains(&x) && (100..130).contains(&y);
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = body || shoulders;
cream[i] = shoulders;
img.px[i] = if print {
[0.9 + n, 0.15 + n, 0.1 + n]
} else if body {
[0.12 + n, 0.2 + n, 0.45 + n]
} else if shoulders {
// Cream, a few levels off the backdrop.
[0.90 + n, 0.88 + n, 0.80 + n]
} else {
[0.96 + n, 0.96 + n, 0.95 + n]
}
.map(|v| v.clamp(0.0, 1.0));
}
}
(img, truth, cream)
}
fn coverage(region: &Region, part: &[bool], w: usize, h: usize) -> f32 {
let (mut hit, mut n) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
if part[y * w + x] {
n += 1;
hit += (region.at(x as i32, y as i32) >= 0.5) as usize;
}
}
}
hit as f32 / n.max(1) as f32
}
#[test]
fn select_subject_keeps_parts_close_to_the_background_colour() {
let (img, truth, cream) = low_contrast_figure(7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap();
let v = iou(&reg, &truth, 240, 200);
let c = coverage(&reg, &cream, 240, 200);
assert!(c > 0.85, "the cream shoulders are part of the subject: covered {c}");
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn select_subject_leaves_a_background_coloured_gap_open() {
// A dark ring (arms on hips) around a patch of plain backdrop: the patch is not the subject.
let (w, h) = (220usize, 200usize);
let mut rng = Rng::new(11);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut gap = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
let ring = (17.0..80.0).contains(&d);
let hole = d < 17.0;
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = ring;
gap[i] = hole;
img.px[i] = if ring { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0));
}
}
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
let g = coverage(&reg, &gap, w, h);
assert!(g < 0.1, "the backdrop inside the ring stays unselected: covered {g}");
let v = iou(&reg, &truth, w, h);
assert!(v > 0.9, "IoU {v}");
}
/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`).
#[test]
#[ignore = "timing only"]
fn select_subject_timing() {
let (w, h) = (3000usize, 2000usize);
let img = RgbImage::from_fn(w, h, |x, y| {
let body = (1100..1900).contains(&x) && (700..1900).contains(&y);
let shoulders = (1000..2000).contains(&x) && (400..700).contains(&y);
let t = ((x * 7 + y * 13) % 17) as f32 / 1700.0;
if body {
[0.12 + t, 0.2, 0.45]
} else if shoulders {
[0.90, 0.88 + t, 0.80]
} else {
[0.96, 0.96 + t, 0.95]
}
});
let s = ImageSampler { img: &img, origin: (0, 0) };
for _ in 0..2 {
let t0 = std::time::Instant::now();
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
eprintln!("select_subject 6 MP: {:?} bbox {:?}", t0.elapsed(), reg.bbox);
}
}
/// Writes the #2788 scenes and the Select Subject mask over them as PPM files into
/// `$SUBJECT_DUMP` (for the PR's before/after pictures).
#[test]
#[ignore = "writes pictures"]
fn select_subject_dump_pictures() {
let Ok(dir) = std::env::var("SUBJECT_DUMP") else { return };
let write = |name: &str, img: &RgbImage, reg: Option<&Region>| {
let mut out = format!("P6\n{} {}\n255\n", img.w, img.h).into_bytes();
for y in 0..img.h {
for x in 0..img.w {
let mut c = img.at(x, y);
if let Some(r) = reg
&& r.at(x as i32, y as i32) >= 0.5
{
c = [c[0] * 0.5 + 0.5, c[1] * 0.5, c[2] * 0.5 + 0.5];
}
out.extend(c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
}
}
std::fs::write(format!("{dir}/{name}.ppm"), out).unwrap();
};
let (img, _, _) = low_contrast_figure(7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200));
write("figure", &img, None);
write("figure-selected", &img, reg.as_ref());
let (w, h) = (220usize, 200usize);
let mut rng = Rng::new(11);
let img = RgbImage::from_fn(w, h, |x, y| {
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
let n = 0.012 * rng.normal();
if (17.0..80.0).contains(&d) { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0))
});
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32));
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
+68 -4
View File
@@ -4,8 +4,10 @@
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
//! cell's two triangles are rasterized in destination space with the source position
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in
//! both the source and the destination (a thin layer stretched far gets as many cells as its
//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of
//! the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
@@ -77,8 +79,11 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
}
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS);
let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS);
// Cells are a few pixels across on both sides of the map: a thin source stretched far (a
// 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight.
let (lu, lv) = mapped_lengths(src_rect, f);
let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS);
let (nx, ny) = (cells(sw, lu), cells(sh, lv));
// Forward-mapped vertices (and their source positions).
let mut verts = Vec::with_capacity((nx + 1) * (ny + 1));
for j in 0..=ny {
@@ -101,6 +106,43 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
warp_triangles(src, src_rect, &verts, &tris, interp)
}
/// Probe lines per axis [`mapped_lengths`] follows through the map.
const PROBE: usize = 16;
/// The longest destination length of a source row and of a source column under `f`, measured
/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero.
fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) {
let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0));
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
let n = PROBE + 1;
let mut pts = Vec::with_capacity(n * n);
for j in 0..n {
for i in 0..n {
let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64);
pts.push([dx, dy]);
}
}
let step = |a: [f64; 2], b: [f64; 2]| {
let d = (b[0] - a[0]).hypot(b[1] - a[1]);
if d.is_finite() { d } else { 0.0 }
};
let (mut lu, mut lv) = (0.0f64, 0.0f64);
for k in 0..n {
let (mut row, mut col) = (0.0, 0.0);
for m in 1..n {
if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) {
row += step(a, b);
}
if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) {
col += step(a, b);
}
}
lu = lu.max(row);
lv = lv.max(col);
}
(lu, lv)
}
/// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and
/// each triangle samples `src` (restricted to `src_rect`) through the barycentric
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
@@ -399,6 +441,28 @@ mod tests {
surf
}
#[test]
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
// source pixels (one cell across), so the arch came out as a straight band.
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]);
let arch = |x: f64, y: f64| {
let dx = x * 100.0;
let t = (dx - 150.0) / 150.0;
(dx, y + 40.0 * (1.0 - t * t))
};
let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear);
let alpha = |x: i32, y: i32| {
let mut p = [0.0f32; 4];
out.read_pixel(x, y, &mut p);
p[3]
};
// The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in.
assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20));
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
}
#[test]
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
+64 -5
View File
@@ -29,8 +29,8 @@ use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
@@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
}
}
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however
/// much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
configure_stream(&stream)?;
let read = stream.try_clone()?;
let mut reader = std::io::BufReader::new(read);
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok {
reader.get_mut().authenticated()?;
}
reply
};
write_reply(&mut out, &reply)?;
@@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token);
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
});
}
Ok(())
@@ -814,6 +819,60 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
/// and one that authenticated in time keeps being served after it.
#[test]
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
use std::io::{BufReader, Write as _};
use std::time::{Duration, Instant};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let h = Mutex::new(Headless::new());
let mut results = Vec::new();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
results.push((r.map_err(|e| e.kind()), started.elapsed()));
}
results
});
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
let mut slow = std::net::TcpStream::connect(addr).unwrap();
for _ in 0..150 {
if slow.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(slow);
// Authenticates at once, then asks again 300 ms after the deadline.
let mut s = std::net::TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
std::thread::sleep(Duration::from_millis(600));
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");
drop(reader);
drop(s);
let results = server.join().unwrap();
let (slow_result, slow_time) = &results[0];
assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}");
assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}");
assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client");
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
+108 -2
View File
@@ -1,11 +1,11 @@
//! Shared security primitives for the loopback JSON control transports.
use std::fs::OpenOptions;
use std::io::{BufRead, Write};
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
@@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16;
pub const MAX_BATCH_STEPS: usize = 256;
/// Idle/read and write timeout for loopback TCP connections.
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts
/// again with every read, so a client that kept sending without ever completing `auth` held one
/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965).
pub const AUTH_DEADLINE: Duration = Duration::from_secs(10);
const TOKEN_BYTES: usize = 32;
const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
@@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> {
stream.set_write_timeout(Some(IO_TIMEOUT))
}
/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail
/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime;
/// the caller then drops the connection and its [`ConnectionPermit`].
pub struct AuthDeadlineStream {
stream: std::net::TcpStream,
/// `None` once authenticated.
deadline: Option<Instant>,
}
impl AuthDeadlineStream {
/// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]).
pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self {
// A limit too large to represent expires at once rather than never.
let now = Instant::now();
Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) }
}
/// The connection authenticated: only the idle timeout applies from here on.
pub fn authenticated(&mut self) -> std::io::Result<()> {
self.deadline = None;
self.stream.set_read_timeout(Some(IO_TIMEOUT))
}
}
impl Read for AuthDeadlineStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(deadline) = self.deadline {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time"));
}
// Wait no longer than what is left, so a silent client is dropped at the deadline too.
self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?;
}
self.stream.read(buf)
}
}
/// Environment-aware token inputs shared by the desktop app and CLI.
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
@@ -327,6 +369,70 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
/// A connected loopback pair: the accepted (server) end and the client end.
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
configure_stream(&server).unwrap();
(server, client)
}
/// #4965: activity must not extend the time a connection may stay unauthenticated.
#[test]
fn an_active_connection_is_dropped_at_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
// A byte every 20 ms and never a newline: each read succeeds long before the idle timeout.
let trickle = std::thread::spawn(move || {
for _ in 0..150 {
if client.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300)));
let mut line = String::new();
let started = Instant::now();
let err = read_bounded_line(&mut reader, &mut line).unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}");
assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed());
assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed());
// It stays expired.
assert!(read_bounded_line(&mut reader, &mut line).is_err());
drop(reader);
trickle.join().unwrap();
}
#[test]
fn a_silent_connection_is_dropped_at_the_authentication_deadline() {
let (server, _client) = tcp_pair();
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
let started = Instant::now();
assert!(read_bounded_line(&mut reader, &mut line).is_err());
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
}
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
let sender = std::thread::spawn(move || {
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
client.write_all(b"two\n").unwrap();
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
sender.join().unwrap();
}
#[test]
fn connection_limiter_releases_capacity() {
let limiter = ConnectionLimiter::new(1);
+4 -1
View File
@@ -325,7 +325,10 @@ pub struct UiSetParams {
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). Other fields are an error.
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
/// their current values. Other fields are an error.
pub fields: Value,
}
+10 -1
View File
@@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@@ -93,7 +94,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
`CodecError::Unsupported` naming the field.
* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from),
read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray)
or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word
boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white
reference points are a log-conversion choice and are not applied. A depth other than 10 bits or
a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than
panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
+174 -21
View File
@@ -23,7 +23,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
use crate::options::Limits;
use crate::options::{Limits, alloc_zeroed};
const F: Format = Format::OpenExr;
@@ -39,9 +39,15 @@ fn unsup(reason: impl Into<String>) -> CodecError {
// Every length and offset comes from the file, so every read is bounds-checked and a short
// read is a malformed file, never a panic.
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut v = vec![0u8; n];
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> {
// Borrow packed payloads from the input. Copying them here allocates before count-table
// admission and needlessly duplicates the table even when the second pass just skips it.
let truncated = || err(format!("truncated file while reading {what}"));
let start = usize::try_from(cur.position()).map_err(|_| truncated())?;
let end = start.checked_add(n).ok_or_else(truncated)?;
let bytes = *cur.get_ref();
let v = bytes.get(start..end).ok_or_else(truncated)?;
cur.set_position(end as u64);
Ok(v)
}
@@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) {
}
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
/// holding every second byte of the original; weave them back together.
fn interleave_byte_blocks(buf: &mut [u8]) {
/// holding every second byte of the original; weave them back together. The scratch copy is
/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error.
fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> {
let half = buf.len().div_ceil(2);
let mut out = vec![0u8; buf.len()];
let mut out = alloc_zeroed::<u8>(buf.len())?;
let mut k = 0usize;
for i in 0..buf.len() / 2 {
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
@@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) {
}
}
buf.copy_from_slice(&out);
Ok(())
}
/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`,
/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and
/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error.
fn reserve_up_to(out: &mut Vec<u8>, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> {
let need = out.len().saturating_add(extra);
if need > limit {
return Err(err(format!("{what}: data expands past {limit} bytes")));
}
if need > out.capacity() {
let target = out.capacity().saturating_mul(2).max(need).min(limit);
out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?;
}
Ok(())
}
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut out = Vec::with_capacity(expected.min(1 << 20));
let mut out = Vec::new();
reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?;
let mut i = 0;
while i < src.len() {
let count = src[i] as i8;
@@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
if out.len().saturating_add(n) > expected {
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
}
reserve_up_to(&mut out, n, expected, what)?;
if count < 0 {
let end = i.saturating_add(n);
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
@@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
/// has the uncompressed length is stored raw whatever the declared compression.
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
if packed.len() == expected {
return Ok(packed.to_vec());
let mut out = Vec::new();
reserve_up_to(&mut out, expected, expected, what)?;
out.extend_from_slice(packed);
return Ok(out);
}
match compression {
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
@@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s
// Like ZIPS, RLE runs over the predicted, byte-split stream.
let mut out = rle_decode(packed, expected, what)?;
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
Compression::ZIP1 => {
let mut out = Vec::with_capacity(expected.min(1 << 20));
flate2::read::ZlibDecoder::new(packed)
.take(expected as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
// Inflate straight into `out`, grown with `reserve_up_to` rather than by
// `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are
// in, a one-byte read checks that the stream ends there.
let malformed = || err(format!("{what}: zlib data is malformed"));
let mut zlib = flate2::read::ZlibDecoder::new(packed);
let mut out = Vec::new();
let mut filled = 0;
while filled < expected {
if filled == out.len() {
let grow = out.len().max(1 << 13).min(expected - filled);
reserve_up_to(&mut out, grow, expected, what)?;
out.resize(filled + grow, 0);
}
let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?;
if n == 0 {
break;
}
filled += n;
}
out.truncate(filled);
if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 {
return Err(err(format!("{what}: zlib data expands past {expected} bytes")));
}
if out.len() != expected {
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
}
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
@@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
return Err(err("deep chunk sizes point past the end of the file"));
}
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let what = "the sample-count table";
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
// A compressed clipped table also needs its scratch copy. The data-window check only
// budgets four bytes per channel per pixel, insufficient for a one-channel image.
// An equal-sized payload takes decompress's raw path, which allocates no scratch.
let buffers = if table_bytes.len() == expected_table { 1 } else { 2 };
let clipped_need = (expected_table as u64).saturating_mul(buffers);
if clipped_need > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!(
"{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",
limits.max_alloc
)));
}
// An edge tile's table may still be stored at the full tile size (its pixels first, in
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
// That size comes from the header's tile size alone, which nothing ties to the data
// window, so it is tried only when `max_alloc` has room for the table and for the copy
// that undoes its byte split. Otherwise only the clipped size is read.
let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4));
let raw = match full_table {
// Scan lines and interior tiles: the full size is the clipped size.
Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?,
Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => {
decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))?
}
_ => decompress(compression, table_bytes, expected_table, what).map_err(|_| {
// Four bytes per pixel of the tile, twice: the table and its scratch copy.
let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8);
CodecError::LimitExceeded(format!(
"{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}",
limits.max_alloc
))
})?,
};
// The table is cumulative within each row of the block (a tile restarts at every row);
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
@@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
// The count table sits between the sizes and the sample data; skip its bytes.
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
// Row by row (a scan line block is one row), and within a row channel by channel, as
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
let mut in_off = 0usize;
@@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() {
let bytes = [1u8, 2, 3, 4];
let mut cur = Cursor::new(bytes.as_slice());
let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes");
assert_eq!(prefix, &[1, 2, 3]);
assert_eq!(prefix.as_ptr(), bytes.as_ptr());
assert_eq!(cur.position(), 3);
assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. })));
assert_eq!(cur.position(), 3);
assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
cur.set_position(u64::MAX);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
}
/// What the writer stores before compressing: the byte split, then the +128 predictor.
fn predicted(raw: &[u8]) -> Vec<u8> {
let half = raw.len().div_ceil(2);
let mut out = vec![0u8; raw.len()];
for (i, &v) in raw.iter().enumerate() {
out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v;
}
let mut prev = 0i16;
for (i, b) in out.iter_mut().enumerate() {
let v = i16::from(*b);
if i > 0 {
*b = (v - prev + 128) as u8;
}
prev = v;
}
out
}
/// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count
/// table, with the copy that undoes its byte split), so decompression must not reserve
/// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table
/// 4 MiB, so the "admitted" table and its copy took up to three times the table size.
#[test]
fn decompressed_buffers_reserve_no_more_than_expected() {
for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] {
let raw = vec![0u8; n];
let p = predicted(&raw);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(&p).expect("write to a Vec");
let zips = z.finish().expect("finish a Vec");
// RLE: the first byte as a literal, then runs of up to 128 copies of the next.
let mut rle = vec![0xFF, p[0]];
for run in p[1..].chunks(128) {
rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]);
}
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
let out = decompress(compression, packed, n, "the table").expect("decompresses");
assert_eq!(out, raw, "{compression:?}, {n} bytes");
assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity());
}
// One byte short, the same data expands past the expected size and is refused.
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes");
}
}
}
/// A size past the limit is malformed data, and a reservation the allocator refuses is an
/// error rather than an abort.
#[test]
fn reservations_past_the_limit_or_the_allocator_are_errors() {
let mut v = Vec::new();
assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. })));
assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_))));
assert_eq!(v.capacity(), 0);
}
}
+128
View File
@@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
// ---------------------------------------------------------------- Cineon (Kodak .cin)
//
// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian).
// The generic header is 1024 bytes: the file information fields, then at offset 192 the image
// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors
// (the first carries the bits per pixel and the line/image dimensions). Image data is three
// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as
// DPX's 10-bit packing method A, so the unpacking is shared.
/// The Cineon magic number (`802A5FD7` hex), big-endian.
pub(crate) fn is_cineon(b: &[u8]) -> bool {
b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7])
}
/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX).
pub(crate) fn cineon_orientation(b: &[u8]) -> u16 {
let Some(code) = b.get(192).copied() else { return 1 };
match code {
1 => 2,
2 => 4,
3 => 3,
4 => 5,
5 => 6,
6 => 8,
7 => 7,
_ => 1,
}
}
/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or
/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the
/// file's black/white reference points are not applied (a log conversion is a display choice).
pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !is_cineon(bytes) {
return Err(err("missing the Cineon magic number (802A5FD7)"));
}
let o = Order::Big;
let missing = |what: &str| err(format!("the header ends before {what}"));
let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?;
let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?;
let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?;
if bits != 10 {
return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)")));
}
let layout = match channels {
1 => ChannelLayout::Gray,
3 => ChannelLayout::Rgb,
n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))),
};
let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?;
let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?;
limits.check(width, height, layout, SampleType::U16)?;
let (w, h) = (width as usize, height as usize);
let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
// Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1).
let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?;
if data.len() < need {
return Err(err("truncated image data"));
}
let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?];
for (y, row) in codes.chunks_exact_mut(per_line).enumerate() {
let start = y * line_len;
let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?;
unpack_line(line, o, 10, 1, row)?;
}
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, 1023)).collect();
Image::from_u16(width, height, layout, &wide)
}
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
let mut text = Vec::new();
@@ -653,4 +725,60 @@ mod tests {
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
}
/// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three
/// to a 32-bit word (RGB in word order, or gray three pixels per word).
fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec<u8> {
let mut b = vec![0u8; 1024];
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7)
b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset
b[192] = 0; // orientation: left-to-right, top-to-bottom
b[193] = channels;
b[198] = 10; // bits per pixel (channel 1)
b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line
b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image
let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes();
if channels == 3 {
for p in px {
b.extend_from_slice(&word(p[0], p[1], p[2]));
}
} else {
let g = |i: usize| px.get(i).map_or(0, |p| p[0]);
for i in (0..px.len()).step_by(3) {
b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2)));
}
}
b
}
#[test]
fn cineon_reads_ten_bit_rgb() {
let px = [[0, 512, 1023], [1023, 0, 256]];
let b = cineon(2, 1, 3, &px);
assert!(is_cineon(&b));
assert_eq!(order(&b), None, "Cineon is not DPX");
let img = decode_cineon(&b, &Limits::default()).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb));
assert_eq!(
img.to_u16_samples().unwrap(),
vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)]
);
}
#[test]
fn cineon_reads_grayscale_and_rejects_other_depths() {
let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]];
let b = cineon(4, 1, 1, &px);
let img = decode_cineon(&b, &Limits::default()).unwrap();
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]);
// A depth other than 10 is unsupported, and truncation errors rather than panicking.
let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]);
bad[198] = 8;
assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. })));
let full = cineon(4, 4, 3, &[[0; 3]; 16]);
for cut in [0, 4, 200, 1024, full.len() - 1] {
assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}");
}
}
}
+69 -5
View File
@@ -532,17 +532,24 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
} else {
None
};
// CIE L*a*b*: CIELab (8; a* and b* signed) and ICCLab (9; a* and b* offset) are handed to the
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
// (`convert_ab`).
let lab = matches!(photometric, 8 | 9);
if lab && (spp < 3 || !matches!(src, Src::U8 | Src::U16)) {
return Err(CodecError::unsupported(F, format!("Lab image with {spp} samples of {bits} bits")));
}
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
// position past the samples the file has is ignored.
let color = match photometric {
2 => 3,
2 | 8 | 9 => 3,
5 => 4,
_ => 1,
};
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp);
let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0);
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0);
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
let encode_u = |v: u64, len: usize| -> Vec<u8> {
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
@@ -557,14 +564,14 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
// The directory is seen as the last of its chain: the chain was walked already, and a
// cycle through it must not fail this page.
patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }]));
if invert || palette.is_some() || all_samples {
if invert || palette.is_some() || all_samples || lab {
let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?;
let len = match e.ty {
3 if e.count == 1 => 2,
4 if e.count == 1 => 4,
_ => return Err(err("malformed PhotometricInterpretation")),
};
patches.push((e.value_at, encode_u(1, len)));
patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len)));
}
let tl = {
@@ -585,7 +592,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
_ if palette.is_some() => (ChannelLayout::Rgb, 1),
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
(if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples))
(if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples))
}
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
@@ -596,6 +603,12 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5),
other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))),
};
let out_layout = match out_layout {
ChannelLayout::Rgb if lab => ChannelLayout::Lab,
ChannelLayout::Rgba if lab => ChannelLayout::LabA,
_ if lab => return Err(err("Lab image that does not read as three colour samples")),
l => l,
};
let chunk_spp = if planar { 1 } else { chunk_spp };
// The sample (chunky) or plane (planar) that holds the alpha: the colour samples come first.
let alpha_sample = out_layout.color_channels() + alpha_at.unwrap_or(0);
@@ -676,6 +689,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
if tally.decoded == 0 && tally.missing > 0 {
return Err(err("the image data is missing (cut off or past the end of the file)"));
}
if lab {
convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false);
}
let mut img = Image::from_raw(w, h, out_layout, out, data)?;
if tally.missing > 0 {
img.warnings.push(DecodeWarning::Truncated { format: F });
@@ -683,6 +699,35 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
Ok(img)
}
/// The a* and b* samples (1 and 2 of every pixel) of a Lab image between the file's encoding and
/// PhotoCraft's (8 bits: `a + 128`; 16 bits: `(a + 128) · 257`). In the file, CIELab holds them
/// signed (8 bits: `a`; 16 bits: `a · 256`) and ICCLab offset (8 bits: `a + 128`; 16 bits:
/// `(a + 128) · 256`). `to_file` picks the direction; samples are native-endian.
fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool, to_file: bool) {
match sample {
SampleType::U8 if signed => data.chunks_exact_mut(channels).for_each(|px| {
px[1] ^= 0x80;
px[2] ^= 0x80;
}),
SampleType::U16 => data.chunks_exact_mut(channels * 2).for_each(|px| {
for c in [1, 2] {
let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]);
// The file's value as `a · 256` (signed) and back.
let file_a256 = |v: u16| if signed { f64::from(v as i16) } else { f64::from(v) - 32768.0 };
let out = if to_file {
let a256 = (f64::from(v) - 32896.0) * 256.0 / 257.0;
let a256 = a256.round().clamp(-32768.0, 32767.0);
if signed { (a256 as i16) as u16 } else { (a256 + 32768.0) as u16 }
} else {
(file_a256(v) * 257.0 / 256.0 + 32896.0).round().clamp(0.0, 65535.0) as u16
};
px[2 * c..2 * c + 2].copy_from_slice(&out.to_ne_bytes());
}
}),
_ => {}
}
}
/// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first
/// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over
/// all cores.
@@ -1021,6 +1066,11 @@ custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint
custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float);
custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int);
custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float);
// CIELab as Photoshop writes it: a* and b* signed, the sample format left unsigned.
custom_colortype!(Lab8, u8, PI::CIELab, &[8; 3], &[SampleFormat::Uint; 3], int);
custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], int);
custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int);
custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int);
struct TagSet<'a> {
icc: Option<&'a [u8]>,
@@ -1119,6 +1169,20 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
(L::CmykA, S::U8) => write_one::<colortype::CMYKA8, K>(enc, w, h, d8, tags),
(L::CmykA, S::U16) => write_one::<CmykA16, K>(enc, w, h, &u16s(), tags),
(L::CmykA, S::F32) => write_one::<CmykA32F, K>(enc, w, h, &f32s(), tags),
(L::Lab | L::LabA, _) => {
// CIELab has no float form: 32-bit Lab is written with 16 bits.
let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 };
let mut file = img.convert(img.layout(), sample);
let channels = file.layout().channels();
convert_ab(file.data_mut(), channels, sample, true, true);
let u16s = || file.to_u16_samples().unwrap_or_default();
match (file.layout(), sample) {
(L::Lab, S::U8) => write_one::<Lab8, K>(enc, w, h, file.data(), tags),
(L::Lab, _) => write_one::<Lab16, K>(enc, w, h, &u16s(), tags),
(_, S::U8) => write_one::<LabA8, K>(enc, w, h, file.data(), tags),
_ => write_one::<LabA16, K>(enc, w, h, &u16s(), tags),
}
}
(l, s) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported {l:?} {s:?}")).into()),
}
}
+12 -2
View File
@@ -30,6 +30,10 @@ pub enum FidelityWarning {
CmykConverted {
to: ChannelLayout,
},
/// L*a*b* data will be converted to another model (sRGB, D50 to D65).
LabConverted {
to: ChannelLayout,
},
/// Colour data will be reduced to grayscale.
ColorToGray,
/// The image will be quantized to a 256-colour palette.
@@ -84,6 +88,7 @@ impl fmt::Display for FidelityWarning {
CmykConverted { to } => {
write!(f, "CMYK will be converted to {to:?} (not colour-managed)")
}
LabConverted { to } => write!(f, "Lab will be converted to {to:?} (sRGB)"),
ColorToGray => write!(f, "colour will be converted to grayscale"),
PaletteQuantized => write!(f, "colours will be quantized to a 256-colour palette"),
LossyCompression => write!(f, "lossy compression"),
@@ -129,8 +134,8 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
if layouts.contains(&cand) {
return cand;
}
// CMYK falls back to RGB; gray expands to RGB.
if cand.is_cmyk() || cand.is_gray() {
// CMYK and Lab fall back to RGB; gray expands to RGB.
if cand.is_cmyk() || cand.is_lab() || cand.is_gray() {
cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
}
if layouts.contains(&cand) {
@@ -158,6 +163,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -210,6 +217,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if sl.is_cmyk() && !p.layout.is_cmyk() {
w.push(W::CmykConverted { to: p.layout });
}
if sl.is_lab() && !p.layout.is_lab() {
w.push(W::LabConverted { to: p.layout });
}
if !sl.is_gray() && p.layout.is_gray() {
w.push(W::ColorToGray);
}
+19 -2
View File
@@ -33,6 +33,8 @@ pub enum Format {
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
Cineon,
}
/// What a format can hold **and** what this crate reads/writes for it.
@@ -78,6 +80,11 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
(
Format::Cineon,
"Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
imported frame as DPX, TIFF or EXR instead.",
),
(
Format::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
@@ -92,10 +99,12 @@ use SampleType as S;
const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba];
const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA];
/// TIFF also stores CIE L*a*b* (CIELab).
const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA, L::Lab, L::LabA];
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 19] = [
pub const ALL: [Format; 20] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -115,6 +124,7 @@ impl Format {
Format::Heif,
Format::Fits,
Format::Dpx,
Format::Cineon,
];
pub fn caps(self) -> FormatCaps {
@@ -148,6 +158,7 @@ impl Format {
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
Format::Cineon => "Cineon",
}
}
@@ -174,6 +185,7 @@ impl Format {
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
Format::Cineon => &["cin"],
}
}
@@ -198,6 +210,7 @@ impl Format {
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
Format::Cineon => "image/x-cin",
}
}
@@ -233,7 +246,7 @@ pub fn caps(format: Format) -> FormatCaps {
FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base }
}
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
Format::Bmp => base,
@@ -253,6 +266,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
}
}
@@ -307,6 +321,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
return Some(Format::Dpx);
}
if crate::codecs::dpx::is_cineon(b) {
return Some(Format::Cineon);
}
if b.starts_with(b"SIMPLE =") {
return Some(Format::Fits);
}
+26 -6
View File
@@ -21,19 +21,31 @@ pub enum ChannelLayout {
/// Ink amounts, `0 = no ink`, `max = full ink` (not Adobe-inverted).
Cmyk,
CmykA,
/// CIE L*a*b* (D50), as PhotoCraft's Lab documents hold it: L* 0..100 as 0..1, a* and b*
/// −128..127 as `(v + 128) / 255` (Photoshop's unsigned encoding).
Lab,
LabA,
}
impl ChannelLayout {
pub const ALL: [ChannelLayout; 6] =
[ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA];
pub const ALL: [ChannelLayout; 8] = [
ChannelLayout::Gray,
ChannelLayout::GrayA,
ChannelLayout::Rgb,
ChannelLayout::Rgba,
ChannelLayout::Cmyk,
ChannelLayout::CmykA,
ChannelLayout::Lab,
ChannelLayout::LabA,
];
/// Total number of channels including alpha.
pub const fn channels(self) -> usize {
match self {
ChannelLayout::Gray => 1,
ChannelLayout::GrayA => 2,
ChannelLayout::Rgb => 3,
ChannelLayout::Rgba | ChannelLayout::Cmyk => 4,
ChannelLayout::Rgb | ChannelLayout::Lab => 3,
ChannelLayout::Rgba | ChannelLayout::Cmyk | ChannelLayout::LabA => 4,
ChannelLayout::CmykA => 5,
}
}
@@ -44,7 +56,7 @@ impl ChannelLayout {
}
pub const fn has_alpha(self) -> bool {
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA)
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA | ChannelLayout::LabA)
}
pub const fn is_gray(self) -> bool {
@@ -59,12 +71,17 @@ impl ChannelLayout {
matches!(self, ChannelLayout::Cmyk | ChannelLayout::CmykA)
}
pub const fn is_lab(self) -> bool {
matches!(self, ChannelLayout::Lab | ChannelLayout::LabA)
}
/// The same colour model with alpha added.
pub const fn with_alpha(self) -> Self {
match self {
ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA,
ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba,
ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA,
ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA,
}
}
@@ -74,12 +91,13 @@ impl ChannelLayout {
ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::Gray,
ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgb,
ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::Cmyk,
ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::Lab,
}
}
/// `true` if both layouts share a colour model (ignoring alpha).
pub const fn same_model(self, other: Self) -> bool {
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk())
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) || (self.is_lab() && other.is_lab())
}
}
@@ -723,6 +741,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3],
ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]],
ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]),
ChannelLayout::Lab | ChannelLayout::LabA => crate::lab::lab_to_srgb([color[0], color[1], color[2]]),
};
match to {
ChannelLayout::Gray | ChannelLayout::GrayA => {
@@ -730,6 +749,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
}
ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb),
ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])),
ChannelLayout::Lab | ChannelLayout::LabA => out.extend_from_slice(&crate::lab::srgb_to_lab(rgb)),
}
}
if to.has_alpha() {
+56
View File
@@ -0,0 +1,56 @@
//! CIE L*a*b* (D50) ⇄ encoded sRGB for [`crate::ChannelLayout::Lab`] samples, the same formulas
//! and constants as `photocraft-color` (this crate stays standalone). Samples are normalized as
//! PhotoCraft holds Lab: L* 0..100 as 0..1, a* and b* −128..127 as `(v + 128) / 255`.
const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21];
/// Linear sRGB → D50 XYZ (Bradford-adapted, as in the ICC sRGB profile).
const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]];
/// D50 XYZ → linear sRGB.
const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]];
fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2])
}
fn to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn to_encoded(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
/// Normalized Lab → encoded sRGB in 0..1 (out-of-gamut colours clipped).
pub(crate) fn lab_to_srgb(n: [f32; 3]) -> [f32; 3] {
let (l, a, b) = (n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0);
let fy = (l + 16.0) / 116.0;
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]];
mul(&XYZ_D50_TO_SRGB, xyz).map(|v| to_encoded(v.clamp(0.0, 1.0)))
}
/// Encoded sRGB → normalized Lab.
pub(crate) fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] {
let xyz = mul(&SRGB_TO_XYZ_D50, rgb.map(|v| to_linear(v.clamp(0.0, 1.0))));
let f = |t: f32| if t > (6.0f32 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f32 / 29.0).powi(2)) + 4.0 / 29.0 };
let (fx, fy, fz) = (f(xyz[0] / D50[0]), f(xyz[1] / D50[1]), f(xyz[2] / D50[2]));
let (l, a, b) = (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz));
[(l / 100.0).clamp(0.0, 1.0), ((a + 128.0) / 255.0).clamp(0.0, 1.0), ((b + 128.0) / 255.0).clamp(0.0, 1.0)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn white_black_and_round_trips() {
let w = srgb_to_lab([1.0; 3]);
assert!((w[0] - 1.0).abs() < 1e-3 && (w[1] - 128.0 / 255.0).abs() < 1e-3 && (w[2] - 128.0 / 255.0).abs() < 1e-3, "{w:?}");
assert!(srgb_to_lab([0.0; 3])[0].abs() < 1e-4);
for rgb in [[0.2, 0.5, 0.8], [0.9, 0.1, 0.3], [0.5; 3]] {
let back = lab_to_srgb(srgb_to_lab(rgb));
assert!(rgb.iter().zip(back).all(|(a, b)| (a - b).abs() < 2e-3), "{rgb:?} → {back:?}");
}
}
}
+4
View File
@@ -19,6 +19,7 @@ pub mod exif_color;
mod fidelity;
mod format;
mod image;
mod lab;
mod options;
pub mod orientation;
pub mod resolution;
@@ -79,6 +80,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Dds => dds::decode(bytes, l),
Format::Fits => fits::decode(bytes, l),
Format::Dpx => dpx::decode(bytes, l),
Format::Cineon => dpx::decode_cineon(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
@@ -97,6 +99,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Tiff => tiff::orientation(bytes, None),
Format::Heif => 1,
Format::Dpx => dpx::orientation(bytes),
Format::Cineon => dpx::cineon_orientation(bytes),
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
};
finish_decode(img, o, opts)
@@ -252,6 +255,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
+151 -1
View File
@@ -118,6 +118,23 @@ fn gen_deep(
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
) -> Vec<u8> {
gen_deep_tables(w, h, channels, compression, tile, counts, sample, false)
}
/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile
/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its
/// clipped width), then zeros.
#[allow(clippy::too_many_arguments)]
fn gen_deep_tables(
w: usize,
h: usize,
channels: &[Chan],
compression: u8,
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
full_tile_tables: bool,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
@@ -216,6 +233,10 @@ fn gen_deep(
table.extend_from_slice(&acc.to_le_bytes());
}
}
assert_eq!(table.len(), table_len(b));
if let (true, Some((tw, th))) = (full_tile_tables, tile) {
table.resize(tw * th * 4, 0);
}
let mut data = Vec::new();
for row in b.pixels.chunks(b.row_w) {
for (ci, ch) in channels.iter().enumerate() {
@@ -237,7 +258,6 @@ fn gen_deep(
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&ct);
c.extend_from_slice(&cd);
assert_eq!(table.len(), table_len(b));
assert_eq!(data.len(), data_len(b));
chunks.push((bi, c));
}
@@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() {
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
}
/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes
/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4
/// image, its table stored at that size, makes a compressed file of tens of KB whose table
/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a
/// budget smaller than both the table must be refused before it is allocated; with room for
/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of
/// ordinary tiles, still decode under a small budget.
#[test]
fn deep_tile_count_table_is_admitted_against_max_alloc() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let tight = budget(1 << 20);
let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
for compression in [0u8, 1, 2] {
let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true);
assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &tight);
assert!(
matches!(got, Err(CodecError::LimitExceeded(_))),
"compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}",
got.map(|d| d.total_samples())
);
let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width()));
// The same tile with its table stored at the clipped size fits the small budget.
let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba);
assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}");
if compression != 0 {
assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len());
}
// The table and its scratch copy are admitted together: 8 MiB.
assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &budget(2 * table - 1));
assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples()));
}
// Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones.
let counts: Vec<u32> = (0..35).map(|p| (p * 3) % 4).collect();
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true);
let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample);
assert_eq!(
decode_deep_exr(&full, &tight).expect("full-size edge tables"),
decode_deep_exr(&clipped, &tight).expect("clipped edge tables"),
"compression {compression}"
);
}
}
/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side,
/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits,
/// where its full size passes the check and is only tried as the data arrives. Each tile is
/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over
/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's
/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for
/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint
/// channels decode after full-size tables like float ones.
#[test]
fn deep_tile_table_admission_edges() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
let side = (1 << 30) - 2;
for compression in [0u8, 1, 2] {
let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba);
for limits in [Limits::none(), Limits::default(), budget(1 << 20)] {
assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}");
}
}
let counts: Vec<u32> = (0..48).map(|p| (p % 3) as u32).collect();
let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c });
let [a, b, g, r, z] = RGBAZ;
let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }];
for channels in [&RGBAZ[..], &half[..], &with_id[..]] {
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true);
let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample);
let what = format!("{} channels, compression {compression}", channels.len());
let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables");
assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}");
}
}
}
/// A one-channel data-window check admits only four bytes per pixel, but a compressed count
/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback;
/// the existing multichannel cases have enough pixel allowance to hide this gap.
#[test]
fn clipped_count_tables_include_scratch_in_the_budget() {
let channels = [Chan { name: "Y", ty: 2 }];
let counts = vec![0u32; 64];
let sample = |_: usize, _: usize| Vec::new();
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
for tile in [None, Some((64, 1)), Some((128, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample);
let what = format!("tile {tile:?}, compression {compression}");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
let required = if compression == 0 { 256 } else { 512 };
assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}");
assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}");
let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}");
if compression == 0 {
assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");
}
}
}
}
/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's
/// compression tag, decides whether a scratch buffer is needed.
#[test]
fn raw_count_tables_need_no_scratch_even_with_compression() {
let channels = [Chan { name: "Y", ty: 2 }];
let limits = Limits { max_alloc: 4, ..Limits::none() };
for tile in [None, Some((1, 1)), Some((2, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new());
let header_end = header_end_of(&bytes);
let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize;
let table_size = chunk + if tile.is_some() { 16 } else { 4 };
let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size"));
assert_eq!(packed, 4, "the four-byte count table is stored raw");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}");
}
}
}
/// Walk the attributes like the format prescribes: the offset table starts after the
/// header's terminating zero byte.
fn header_end_of(bytes: &[u8]) -> usize {
+11
View File
@@ -187,6 +187,17 @@ fn detect_magic_hdr() {
assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr));
}
#[test]
fn detect_magic_cineon() {
let mut b = vec![0u8; 208];
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]);
assert_eq!(detect(&b), Some(Format::Cineon));
assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon));
assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only");
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon));
// A bare magic is an error, never a panic.
assert!(decode_as(Format::Cineon, &b).is_err());
}
#[test]
fn detect_magic_avif() {
assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif));
+10 -1
View File
@@ -21,7 +21,13 @@ fn png_u16_rgba_is_clean() {
fn tiff_is_clean_for_all_integer_and_f32_layouts() {
for l in ChannelLayout::ALL {
for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] {
assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}");
let w = warns(&test_image(l, s), Format::Tiff);
if l.is_lab() && s.is_float() {
// CIELab has no float form: it is written with 16 bits, and says so.
assert_eq!(w, vec![FidelityWarning::DepthReduced { from: s, to: SampleType::U16 }], "{l:?} {s:?}");
} else {
assert!(w.is_empty(), "{l:?} {s:?}");
}
}
}
}
@@ -178,6 +184,7 @@ fn every_warning_has_a_message() {
W::AlphaDiscarded,
W::AlphaBinarized,
W::CmykConverted { to: ChannelLayout::Rgb },
W::LabConverted { to: ChannelLayout::Rgb },
W::ColorToGray,
W::PaletteQuantized,
W::LossyCompression,
@@ -209,6 +216,8 @@ fn warnings_match_actual_roundtrip_behaviour() {
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}");
let lab_lost = l.is_lab() && !back.layout().is_lab();
assert_eq!(w.iter().any(|w| matches!(w, W::LabConverted { .. })), lab_lost, "{f:?} {l:?} {s:?}");
}
}
}
+8
View File
@@ -196,6 +196,14 @@ fn tiff_all_compressions_lossless() {
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
let opts = EncodeOptions { tiff_compression: comp, ..Default::default() };
let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap();
if layout.is_lab() && sample != SampleType::U8 {
// CIELab holds a* and b* as 256·a at 16 bits and has no float form: 16-bit and
// float Lab come back within one 16-bit step (the PSD's 16-bit Lab does the same).
let (a, b) = (img.to_normalized(), back.convert(layout, sample).to_normalized());
let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.5 / 65535.0, "{layout:?} {sample:?} {comp:?}: {worst}");
continue;
}
assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}");
}
}
+76
View File
@@ -0,0 +1,76 @@
//! CIE L*a*b* TIFFs: CIELab (PhotometricInterpretation 8) as Photoshop writes it, with a* and b*
//! signed, and ICCLab (9) with them offset, both read into the Lab layout (a* and b* as
//! `(a + 128) / 255`).
use photocraft_codecs::*;
/// One Lab pixel in the codec's normalized form.
fn lab(l: f32, a: f32, b: f32) -> [f32; 3] {
[l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0]
}
fn image(sample: SampleType) -> Image {
let px: Vec<f32> = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat();
Image::from_normalized(2, 1, ChannelLayout::Lab, sample, &px).unwrap()
}
fn uncompressed(img: &Image) -> Vec<u8> {
encode(img, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap()
}
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
#[test]
fn eight_bit_cielab_holds_a_and_b_signed() {
let file = uncompressed(&image(SampleType::U8));
// L* 50 → 128, a* 50, b* −30 as a signed byte; then white with neutral a* and b*.
assert!(find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).is_some(), "CIELab samples");
let back = decode(&file).unwrap();
assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U8));
assert_eq!(back.data(), image(SampleType::U8).data());
}
#[test]
fn sixteen_bit_cielab_holds_256_a() {
let file = uncompressed(&image(SampleType::U16));
let le = &file[..2] == b"II";
let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() };
// a* 50 → 12800, b* −30 → −7680, white's a* and b* 0.
let want: Vec<u8> = [32768u16, 12800, (-7680i16) as u16, 65535, 0, 0].iter().flat_map(|v| u16b(*v)).collect();
assert!(find(&file, &want).is_some(), "CIELab samples");
let back = decode(&file).unwrap();
assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16));
let (a, b) = (image(SampleType::U16).to_normalized(), back.to_normalized());
assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{a:?} {b:?}");
}
#[test]
fn icclab_reads_with_offset_a_and_b() {
// The CIELab file turned into ICCLab: PhotometricInterpretation 9, a* and b* + 128.
let mut file = uncompressed(&image(SampleType::U8));
let at = find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).unwrap();
for i in [1, 2, 4, 5] {
file[at + i] ^= 0x80;
}
let le = &file[..2] == b"II";
let entry: Vec<u8> = if le { [262u16.to_le_bytes(), 3u16.to_le_bytes()].concat() } else { [262u16.to_be_bytes(), 3u16.to_be_bytes()].concat() };
let e = find(&file, &entry).expect("PhotometricInterpretation entry");
let nine = if le { 9u16.to_le_bytes() } else { 9u16.to_be_bytes() };
file[e + 8..e + 10].copy_from_slice(&nine);
let back = decode(&file).unwrap();
assert_eq!(back.layout(), ChannelLayout::Lab);
assert_eq!(back.data(), image(SampleType::U8).data());
}
#[test]
fn lab_converts_to_rgb_for_other_formats() {
// White and a saturated orange-red; PNG has no Lab, so the encoder plan converts to RGB.
let img = image(SampleType::U8);
let png = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap();
assert!(png.layout().is_rgb());
let p = png.to_rgba8();
assert_eq!(&p[4..7], &[255, 255, 255], "white");
assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]);
}
+191
View File
@@ -0,0 +1,191 @@
//! A smart object's filter mask (#215): how the filtered pixels and the unfiltered (placed) ones
//! combine.
//!
//! Photoshop stores a smart object's layer pixels with its filters applied but not its filter
//! mask, and mixes the unfiltered pixels back in through the mask when it composites (photoshop
//! corpus `sf-filter-mask-hard.psd` and `sf-filter-mask-gradient.psd`: the stored pixels are
//! filtered everywhere, the merged image only where the mask shows the filter). Our smart objects
//! keep the mixed result as their pixels, so PSD import mixes ([`mix`]) and export unmixes
//! ([`unmix`]).
use photocraft_doc::LayerMask;
use photocraft_raster::Surface;
/// `filtered` mixed over `base` (the unfiltered pixels) through `mask`: premultiplied, any
/// channel layout. Pixels the mask hides entirely are `base`'s.
pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface {
let fmt = base.format();
let area = base.content_bounds().union(&filtered.content_bounds());
let mut out = filtered.clone();
if area.is_empty() || filtered.format() != fmt {
return out;
}
let n = fmt.channels();
let (b, f) = (base.read_region(area), filtered.read_region(area));
let mut k = Vec::new();
mask.values_into(area, &mut k);
let mut o = vec![0.0f32; b.len()];
for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
if !fmt.alpha {
for c in 0..n {
op[c] = bp[c] + (fp[c] - bp[c]) * m;
}
continue;
}
let a = n - 1;
let oa = bp[a] + (fp[a] - bp[a]) * m;
op[a] = oa;
for c in 0..a {
let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m;
op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 };
}
}
out.write_region(area, &o);
out.prune();
out
}
/// The mask value below which a pixel's filtered value is taken to be unknown: [`unmix`] gives the
/// unfiltered value there, which the mask shows (almost) none of anyway.
const HIDDEN: f32 = 1.0 / 1024.0;
/// The inverse of [`mix`]: the filtered pixels that mix with `base` through `mask` to `mixed`
/// (clamped to 0..1). Where the mask hides the filter entirely they are `base`'s, which Photoshop
/// doesn't show there.
pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface {
let fmt = base.format();
let area = base.content_bounds().union(&mixed.content_bounds());
let mut out = mixed.clone();
if area.is_empty() || mixed.format() != fmt {
return out;
}
let n = fmt.channels();
let (b, x) = (base.read_region(area), mixed.read_region(area));
let mut k = Vec::new();
mask.values_into(area, &mut k);
let mut o = vec![0.0f32; b.len()];
for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
if m < HIDDEN {
// A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the
// stored pixels don't grow past the content.
if !fmt.alpha || bp[n - 1] > 0.0 {
op.copy_from_slice(bp);
}
continue;
}
if !fmt.alpha {
for c in 0..n {
op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0);
}
continue;
}
let a = n - 1;
let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0);
if fa <= 0.0 {
continue;
}
op[a] = fa;
for c in 0..a {
let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m;
op[c] = (pm / fa).clamp(0.0, 1.0);
}
}
out.write_region(area, &o);
out.prune();
out
}
#[cfg(test)]
mod tests {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::LayerMask;
use photocraft_raster::Surface;
use super::*;
fn surface(fmt: PixelFormat, f: impl Fn(i32, i32) -> Vec<f32>) -> Surface {
let mut s = Surface::new(fmt);
for y in 0..4 {
for x in 0..8 {
s.write_pixel(x, y, &f(x, y));
}
}
s
}
/// A mask ramping from hidden (left) to shown (right).
fn ramp() -> LayerMask {
let mut m = LayerMask::reveal_all();
m.surface = surface(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), |x, _| vec![x as f32 / 7.0]);
m
}
#[test]
fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() {
for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] {
let base = surface(fmt, |x, y| {
let mut p = vec![0.2 + 0.05 * x as f32, 0.6, 0.1 * y as f32];
if fmt.alpha {
p.push(0.5 + 0.06 * x as f32);
}
p
});
let filtered = surface(fmt, |_, y| {
let mut p = vec![0.9 - 0.1 * y as f32, 0.3, 0.5];
if fmt.alpha {
p.push(0.8);
}
p
});
let m = ramp();
let mixed = mix(&base, &filtered, &m);
// Hidden column: the unfiltered pixels; shown column: the filtered ones.
assert!(mixed.pixel(0, 2).iter().zip(base.pixel(0, 2)).all(|(a, b)| (a - b).abs() < 1e-6));
assert!(mixed.pixel(7, 2).iter().zip(filtered.pixel(7, 2)).all(|(a, b)| (a - b).abs() < 1e-6));
let back = unmix(&base, &mixed, &m);
for y in 0..4 {
for x in 1..8 {
let (g, w) = (back.pixel(x, y), filtered.pixel(x, y));
assert!(g.iter().zip(&w).all(|(a, b)| (a - b).abs() < 1e-4), "{fmt:?} ({x}, {y}): {g:?} vs {w:?}");
}
// Where the mask hides the filter entirely its value is unknown: the base stands in.
assert_eq!(back.pixel(0, y), base.pixel(0, y));
}
// So mixing the unmixed pixels gives the mixed ones back everywhere.
let again = mix(&base, &back, &m);
for y in 0..4 {
for x in 0..8 {
assert!(again.pixel(x, y).iter().zip(mixed.pixel(x, y)).all(|(a, b)| (a - b).abs() < 1e-4));
}
}
}
}
#[test]
fn transparent_base_pixels_stay_empty() {
// Photoshop's unfiltered cache carries white in transparent pixels; where the mask hides
// the filter those must not become content of the stored pixels.
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let base = surface(fmt, |x, _| if x < 4 { vec![1.0, 1.0, 1.0, 0.0] } else { vec![0.3, 0.4, 0.5, 1.0] });
let mut hidden = LayerMask::reveal_all();
hidden.surface = Surface::with_default(PixelFormat::GRAY8, &[0.0]);
let mixed = mix(&base, &base, &hidden);
let back = unmix(&base, &mixed, &hidden);
assert_eq!(back.pixel(1, 1), vec![0.0; 4]);
assert_eq!(back.content_bounds().x0, 4);
}
#[test]
fn mismatched_or_empty_surfaces_are_left_alone() {
let rgba = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, true);
let a = surface(rgba, |_, _| vec![0.5, 0.5, 0.5, 1.0]);
let b = surface(gray, |_, _| vec![0.1, 1.0]);
assert_eq!(mix(&a, &b, &ramp()), b);
assert_eq!(unmix(&a, &b, &ramp()), b);
let empty = Surface::new(rgba);
assert_eq!(unmix(&empty, &empty, &ramp()), empty);
}
}
+26 -18
View File
@@ -21,6 +21,7 @@ pub mod advanced;
pub mod bounds;
pub mod effects;
pub mod fill_layout;
pub mod filter_mask;
pub mod gradient_fill;
pub mod masks;
pub mod multichannel;
@@ -562,14 +563,14 @@ impl LayerKeys {
fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 {
let addr = std::ptr::from_ref(layer) as usize;
let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr));
if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) {
return *id;
}
let id = self.compute(layer);
if in_doc {
self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id);
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
// Computing an identity only hashes immutable layer data; it does not run Rayon
// work or re-enter this memo. Keep the miss and insertion atomic across callers.
*ids.entry(addr).or_insert_with(|| self.compute(layer))
} else {
self.compute(layer)
}
id
}
fn compute(&self, layer: &Layer) -> u64 {
@@ -577,7 +578,7 @@ impl LayerKeys {
#[cfg(test)]
self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut h = std::collections::hash_map::DefaultHasher::new();
layer_identity(layer, &mut h);
layer_identity(layer, &mut h, &mut |_| {});
h.finish()
}
}
@@ -1531,11 +1532,12 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O
/// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If
/// and (for groups) children.
/// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the
/// layer so those tiles (and their addresses) stay alive while the entry exists.
fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) {
/// Pixels are identified by their copy-on-write tile pointers. `pin` sees every tile hashed: a
/// cache entry keeps a [`std::sync::Weak`] to each, which reserves the tile's address (so no
/// other tile can reuse it and match the key) without keeping its pixels alive (#4530).
fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: &mut dyn FnMut(&std::sync::Arc<photocraft_raster::Tile>)) {
use std::hash::{Hash, Hasher};
fn surface_fp(s: &Surface) -> u64 {
fn surface_fp(s: &Surface, pin: &mut dyn FnMut(&std::sync::Arc<photocraft_raster::Tile>)) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
s.format().hash(&mut h);
// Missing tiles read the default pixel, so tile identity alone is not pixel identity.
@@ -1543,6 +1545,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
sample.to_bits().hash(&mut h);
}
let tiles = s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| {
pin(t);
let mut x = (std::sync::Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 40) ^ ((c.ty as u32 as u64) << 8);
x = x.wrapping_mul(0x9e37_79b9_7f4a_7c15);
acc.wrapping_add(x ^ (x >> 29))
@@ -1563,7 +1566,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
// composites (unlike the group's own visibility and opacity) shapes them.
(c.visible, c.opacity.to_bits(), c.clipped).hash(h);
format!("{:?}", c.blend).hash(h);
layer_identity(c, h);
layer_identity(c, h, pin);
}
format!("{:?}", g.artboard).hash(h);
}
@@ -1575,14 +1578,14 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
if let Some(c) = &layer.fill_cache
&& c.fill == *f
{
surface_fp(&c.surface).hash(h);
surface_fp(&c.surface, pin).hash(h);
}
}
LayerContent::Adjustment(a) => format!("{a:?}").hash(h),
_ => layer.surface().map_or(0, surface_fp).hash(h),
_ => layer.surface().map_or(0, |s| surface_fp(s, pin)).hash(h),
}
if let Some(m) = &layer.mask {
(surface_fp(&m.surface), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
(surface_fp(&m.surface, pin), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
}
if let Some(vm) = &layer.vector_mask {
format!("{vm:?}").hash(h);
@@ -1597,7 +1600,8 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash
struct FxEntry {
maps: std::sync::Arc<effects::FxMaps>,
_pin: Layer,
/// The tiles the key identifies (see [`layer_identity`]): their addresses, not their pixels.
_pin: Vec<std::sync::Weak<photocraft_raster::Tile>>,
bytes: usize,
}
@@ -1716,9 +1720,13 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s
None => {
let shape: Vec<f32> = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
let bytes = maps.bytes();
let mut pin = Vec::new();
layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t)));
// The maps, plus what each reserved tile address keeps allocated.
let bytes =
maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::<photocraft_raster::Tile>() + 2 * std::mem::size_of::<usize>()));
let maps = std::sync::Arc::new(maps);
if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() {
if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() {
// Counted exactly once, by the build that filled the slot, and only while the
// slot is still cached (eviction may have dropped it meanwhile).
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
+49
View File
@@ -3165,3 +3165,52 @@ fn a_pass_through_clipping_base_keeps_its_adjustments_inside() {
d.layers.push(clip);
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1));
}
// A cache entry reserves the addresses of the tiles its key hashes, not their pixels (#4530): once
// the document lets go of a styled layer or group (an edit replaced its tiles, or it was
// deleted), the effect cache keeps none of its pixel memory alive, and the maps are still
// reused while the layer is unchanged.
#[test]
fn effect_cache_keeps_no_pixels_of_a_dropped_layer() {
let mut d = doc_white(300, 300);
let mut l = solid_layer("styled", Rect::new(20, 20, 280, 280), [0.9, 0.2, 0.2, 1.0]);
l.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
d.layers.push(l);
let mut g = Layer::group("g", vec![solid_layer("child", Rect::new(40, 40, 120, 120), [0.1, 0.2, 0.9, 1.0])]);
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
d.layers.push(g);
let first = render(&d, d.bounds());
let tile = |l: &Layer| l.surface().and_then(|s| s.tiles().next()).map(|(_, t)| t.clone()).unwrap();
let own = tile(&d.layers[1]);
let LayerContent::Group(grp) = &d.layers[2].content else { panic!("not a group") };
let child = tile(&grp.children[0]);
assert_eq!(render(&d, d.bounds()).px, first.px, "warm render");
drop(d);
// Only this test's handles are left: the cache pins neither the layer's nor the child's pixels.
assert_eq!(std::sync::Arc::strong_count(&own), 1);
assert_eq!(std::sync::Arc::strong_count(&child), 1);
}
// Concurrent callers share one memoized effect identity.
#[test]
fn concurrent_effect_identity_misses_compute_once() {
let mut doc = identity_doc(SampleType::U8);
// Make the existing pure identity hash long enough to expose overlapping misses.
doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512];
let layer = &doc.layers[1];
let keys = LayerKeys::default();
let start = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
let handles: Vec<_> = (0..8)
.map(|_| {
scope.spawn(|| {
start.wait();
keys.identity(layer, Some(&doc))
})
})
.collect();
let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(ids.iter().all(|id| *id == ids[0]));
});
assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity");
}
+50
View File
@@ -661,3 +661,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() {
let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err();
assert!(err.to_string().contains("bad point"), "{err}");
}
/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick
/// Mask selects everything but the original rectangle, whichever way the colour reads. With
/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha
/// channel inverts to white.
#[test]
fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() {
for depth in DEPTHS {
let mut s = session(depth, "rgb");
let pixels = rgba(&s, 2, 2);
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap();
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap();
let q = &doc(&s).quick_mask.as_ref().unwrap().surface;
let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0));
assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}");
s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap();
let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask");
let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0));
assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}");
assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched");
s.execute("select.deselect", json!({})).unwrap();
s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap();
s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap();
let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0);
assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})");
s.undo();
assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black");
// Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected
// comes back as everything selected.
for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] {
let mut s = session(depth, "rgb");
s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap();
if start == "rectangle" {
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap();
}
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap();
s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap();
let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask"));
let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0));
assert!(
(got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01,
"{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}"
);
}
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
// As in `Session::edit`: undo targets what was selected just before the step (#1356).
st.history.set_current_layers(prior);
st.history.record(label, before, st.layer_target());
st.history.trim(&st.doc);
st.trim_history();
st.coalesce = None;
Ok(v)
}
+22
View File
@@ -24,6 +24,22 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
/// Photoshop ExtendScript / UXP script suffixes — not PhotoCraft JSON or command lists.
fn extendscript_ext(path: &str) -> Option<&str> {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let dot = name.rfind('.')?;
let ext = &name[dot + 1..];
let lower = ext.to_ascii_lowercase();
matches!(lower.as_str(), "js" | "jsx" | "jsxbin" | "psjs" | "ccjs").then_some(ext)
}
fn reject_extendscript(cmd: &str, path: &str) -> Result<()> {
match extendscript_ext(path) {
Some(_) => Err(bad(cmd, "PhotoCraft scripts are JSON or command lists (one command.id {json} per line), not Photoshop ExtendScript (.js/.jsx)")),
None => Ok(()),
}
}
/// Session state of the File-menu commands (not saved, not history).
#[derive(Clone, Debug, Default)]
pub struct FileMenuState {
@@ -127,6 +143,12 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec<Value> {
/// File › Scripts › Browse…: run a script file on the session.
fn browse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.scripts.browse";
if let Some(path) = p.get("path").and_then(Value::as_str) {
reject_extendscript(cmd, path)?;
}
if let Some(name) = p.get("name").and_then(Value::as_str) {
reject_extendscript(cmd, name)?;
}
let steps = match (p.get("path").and_then(Value::as_str), p.get("script").and_then(Value::as_str), p.get("steps")) {
(Some(path), _, _) => parse_script(&String::from_utf8_lossy(&read_file(path)?))?,
(_, Some(text), _) => parse_script(text)?,
+21
View File
@@ -54,6 +54,27 @@ fn browse_runs_a_script_file() {
assert_eq!(r["results"].as_array().unwrap().len(), 1);
}
#[test]
fn browse_rejects_photoshop_extendscript() {
let dir = tmp("jsx");
let jsx = format!("{dir}/hello.jsx");
std::fs::write(&jsx, "alert('hi');\n").unwrap();
let mut s = Session::new();
let err = s.execute("file.scripts.browse", json!({"path": jsx})).unwrap_err().to_string();
assert!(err.contains("not Photoshop ExtendScript"), "{err}");
assert!(err.contains("JSON"), "{err}");
let js = format!("{dir}/hello.js");
std::fs::write(&js, "alert('hi');\n").unwrap();
assert!(s.execute("file.scripts.browse", json!({"path": js})).is_err());
let err = s.execute("file.scripts.browse", json!({"name": "Resize.jsx", "script": "alert(1);"})).unwrap_err().to_string();
assert!(err.contains(".js/.jsx"), "{err}");
// A JSON script still runs even if the file name is not a Photoshop suffix.
let json = format!("{dir}/rotate.json");
std::fs::write(&json, r#"[["file.new", {"width": 8, "height": 8}]]"#).unwrap();
let r = s.execute("file.scripts.browse", json!({"path": json})).unwrap();
assert_eq!(r["ok"], true);
}
#[test]
fn recording_browse_script_captures_each_effect_once() {
let mut s = Session::new();
+3 -8
View File
@@ -242,14 +242,9 @@ pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Opt
translate: false,
gray: Some(GraySurface::Alpha(i)),
}),
ChannelTarget::Composite if doc.quick_mask.is_some() => Some(IsolatedCurveChannel {
name: "Quick Mask".into(),
key: "",
hist: 0,
tint: false,
translate: true,
gray: Some(GraySurface::QuickMask),
}),
ChannelTarget::Composite if doc.quick_mask.is_some() => {
Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) })
}
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
_ => None,
+13
View File
@@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec<LayerId> {
if group.len() > 1 { group } else { Vec::new() }
}
/// The base `id` clips to (itself when it is a base), when it is in a clip group.
pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option<LayerId> {
group_of(doc, id).first().copied()
}
/// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself
/// clipped (it is not a base) or nothing is clipped to it.
fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
@@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
}
}
/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them
/// with it).
pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec<LayerId> {
let Some(path) = doc.path_of(id) else { return Vec::new() };
let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() };
list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect()
}
/// The clip groups of layers about to move or be deleted, read before they leave their slots.
#[derive(Default)]
pub(crate) struct Before {
+100 -44
View File
@@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
}
/// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in
/// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which
/// becomes the base; Release releases every selected layer. One history step either way.
/// becomes the base; Release releases every selected layer. Releasing a clipped layer also
/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected
/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer
/// that is no longer clipped. One history step either way.
fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" };
let ids: Vec<LayerId> = if p.get("layer").is_some() {
@@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
(false, all) => all.to_vec(),
}
};
let ids = if clip {
ids
} else {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut all = ids.clone();
for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) {
for above in crate::clip_group::clipped_above(doc, *id) {
if !all.contains(&above) {
all.push(above);
}
}
}
all
};
s.edit(label, |doc, _| {
for id in &ids {
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip;
@@ -381,12 +398,12 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
});
}
if crate::channel_cmds::is_channel_target(p) {
// Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel.
// Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel as on
// a layer mask, its unstored black included, so ⌘I in Quick Mask inverts it all (#6095).
return s.edit(label, |doc, _| {
let sel = doc.selection.clone();
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? {
pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale);
surf.prune();
pixels::adjust_mask(surf, &adj, sel.as_ref());
}
Ok(Value::Null)
});
@@ -724,10 +741,32 @@ fn build() -> Vec<CommandSpec> {
"Layer Properties",
[],
None,
r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##,
has_layer,
|s, p| {
let id = layer_param(s, p)?;
// Several layers at once (the Layers panel with several selected, as in Photoshop),
// else the given or active one.
let ids: Vec<LayerId> = match p.get("layers") {
None | Some(Value::Null) => vec![layer_param(s, p)?],
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut ids = Vec::with_capacity(a.len());
for v in a {
let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?;
if doc.layer(id).is_none() {
return Err(EngineError::NoLayer(id));
}
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))),
};
if ids.len() > 1 && p.get("name").is_some() {
return Err(bad("layer.setProps", "`name` renames one layer, not several"));
}
// Opacity and Fill are fractions here, while the Layer Style commands take
// percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242).
let unit = |key: &str| -> Result<Option<f32>> {
@@ -738,9 +777,19 @@ fn build() -> Vec<CommandSpec> {
}
};
let (opacity, fill) = (unit("opacity")?, unit("fill")?);
let only_visibility =
p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce")));
let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" };
// Photoshop's History names for a single kind of change; a mix is "Layer Properties".
let changed: Vec<&str> = p
.as_object()
.map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect())
.unwrap_or_default();
let label = match changed.as_slice() {
["visible"] => "Layer Visibility",
["opacity"] => "Opacity Change",
["fill"] => "Fill Opacity Change",
["blend"] => "Blending Change",
["name"] => "Rename Layer",
_ => "Layer Properties",
};
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
// Clipping and channel changes reach the layers around it: recomposite everything.
let local = p.get("clipped").is_none() && p.get("channels").is_none();
@@ -749,47 +798,54 @@ fn build() -> Vec<CommandSpec> {
// one that changes its look turns it into a normal layer ("Layer 0") first.
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal);
if num("opacity") || num("fill") || blends {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
l.name = v.to_string();
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
l.visible = v;
}
if let Some(v) = opacity {
l.opacity = v;
}
if let Some(v) = fill {
l.fill_opacity = v;
}
if let Some(v) = p.get("blend").and_then(Value::as_str) {
l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
}
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
l.clipped = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
l.locks.all = v;
}
if let Some(Value::Array(ch)) = p.get("channels") {
// Blending Options › Advanced Blending › Channels: unchecked = left out.
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
}
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
for &id in &ids {
if num("opacity") || num("fill") || blends {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
l.name = v.to_string();
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
l.visible = v;
}
if let Some(v) = opacity {
l.opacity = v;
}
if let Some(v) = fill {
l.fill_opacity = v;
}
if let Some(v) = p.get("blend").and_then(Value::as_str) {
let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?;
// Pass Through is a group's mode only, as in Photoshop.
if b == photocraft_color::BlendMode::PassThrough && !l.is_group() {
return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0)));
}
l.blend = b;
}
if let Some(v) = p.get("clipped").and_then(Value::as_bool) {
l.clipped = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
l.locks.all = v;
}
if let Some(Value::Array(ch)) = p.get("channels") {
// Blending Options › Advanced Blending › Channels: unchecked = left out.
l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i);
}
if let Some(Value::Object(m)) = p.get("locks") {
for (k, v) in m {
let v = v.as_bool().unwrap_or(false);
if !l.locks.set(k, v) {
return Err(bad("layer.setProps", format!("unknown lock `{k}`")));
}
}
}
}
Ok(())
})?;
if local {
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
crate::layer_multi_cmds::note_damage(s, &before, &ids);
}
Ok(Value::Null)
}
+7 -1
View File
@@ -224,7 +224,13 @@ fn fade(s: &mut Session, p: &Value) -> Result<Value> {
fn history_bytes(s: &Session, all: bool) -> usize {
let docs: Vec<&crate::DocState> = if all { s.documents().iter().collect() } else { s.active().into_iter().collect() };
docs.iter().map(|d| d.history.unique_bytes(&d.doc)).sum()
// Purge keeps the snapshots, so the tiles they share with the states aren't freed.
docs.iter()
.map(|d| {
let snaps: Vec<&photocraft_doc::Document> = d.snapshots.iter().map(|s| &*s.doc).collect();
d.history.unique_bytes_besides(&d.doc, &snaps)
})
.sum()
}
fn clip_bytes(s: &Session) -> usize {
+12 -1
View File
@@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul
n
}
let backup = doc.layers.clone();
// The clip groups of the doomed layers, read before they go (clip_group rule 3: layers
// clipped to a deleted base are released).
let doomed: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect();
let before = crate::clip_group::Before::read(doc, &doomed);
let n = rec(&mut doc.layers, pred);
if doc.layers.is_empty() {
doc.layers = backup;
return Err(EngineError::Other("a document must keep at least one layer".into()));
}
crate::clip_group::settle(doc, &[], &before, false);
if active.is_none_or(|id| doc.layer(id).is_none()) {
*active = doc.top_layer();
}
@@ -925,7 +930,13 @@ pub fn specs() -> Vec<CommandSpec> {
for id in ids {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects = fx.clone();
l.blend = style.blend;
// Pass Through is a group's mode: a group's style pastes onto other layers as
// Normal, as a merged group's Pass Through becomes Normal.
l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() {
photocraft_color::BlendMode::Normal
} else {
style.blend
};
l.fill_opacity = style.fill;
l.advanced = style.advanced;
l.blend_if = blend_if.clone();
+52 -2
View File
@@ -8,8 +8,10 @@
//! states: `history.newSnapshot` takes one, `history.selectSnapshot` brings it back as a new,
//! undoable state. They live on [`crate::DocState::snapshots`] for the session only, as in
//! Photoshop, and are addressed by an id that is never reused. Their tiles are shared
//! copy-on-write with the document and its history, and are not counted in the history's pixel
//! budget.
//! copy-on-write with the document and its history; the tiles only snapshots hold count against
//! the history's pixel budget ([`crate::DocState::trim_history`]), so a Merged Layers snapshot
//! (a full flattened copy) makes room by dropping the oldest states rather than taking memory
//! beyond the budget unseen (#4530). Edit › Purge keeps them.
use std::sync::Arc;
@@ -451,6 +453,54 @@ mod tests {
assert_eq!(s.active().unwrap().doc.layers.len(), 2);
}
fn tile_bytes(doc: &Document) -> usize {
doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).sum()
}
/// A Merged Layers snapshot is a flattened copy only it holds (#4530): it counts in the
/// document's memory and against the history budget, so the same edits keep one state less.
#[test]
fn merged_snapshot_counts_against_the_history_budget() {
let (mut with, mut without) = (session(), session());
let base = with.active().unwrap().memory_bytes();
with.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap();
let st = with.active().unwrap();
let flat = tile_bytes(&st.snapshots[0].doc);
assert!(flat > 0);
assert_eq!(st.memory_bytes(), base + flat);
// Room for the document's tiles and four fill tiles.
let cur = tile_bytes(&st.doc);
let fill = tile_bytes(&Document { layers: st.doc.layers[1..].to_vec(), ..(*st.doc).clone() });
assert!(flat <= fill, "the flattened tile is no bigger than a fill's");
let budget = cur + 4 * fill;
for s in [&mut with, &mut without] {
s.active_mut().unwrap().history.max_bytes = budget;
for i in 0..6 {
s.execute("edit.fill", json!({"color": format!("#0000{:02x}", 0x40 + i * 0x20)})).unwrap();
}
}
let held = |s: &Session| s.active().unwrap().history.past_len();
assert_eq!(held(&without), 4);
assert_eq!(held(&with), 3, "the snapshot's tile takes one state's room");
assert!(with.active().unwrap().memory_bytes() <= budget);
}
/// Purge › Histories keeps the snapshots, so it doesn't report the tiles they share with
/// the states as freed (#4530).
#[test]
fn purging_histories_frees_only_what_no_snapshot_keeps() {
let mut s = session();
s.execute("history.newSnapshot", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
let st = s.active().unwrap();
// The snapshot's green layer tile, now only in it and the last state.
let shared = tile_bytes(&Document { layers: st.snapshots[0].doc.layers[1..].to_vec(), ..(*st.doc).clone() });
let all = st.history.unique_bytes(&st.doc);
let r = s.execute("edit.purge.histories", json!({})).unwrap();
assert_eq!(r["bytes"], json!(all - shared), "the green tile stays with the snapshot");
assert_eq!(s.active().unwrap().snapshots.len(), 1);
}
#[test]
fn rename_and_delete_snapshots_keep_the_other_ids() {
let mut s = session();
+1 -11
View File
@@ -671,17 +671,7 @@ impl Session {
return Ok(Started::Done(v));
}
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, &params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = crate::hidden_target::refusal(self, id, &params) {
return Err(EngineError::Other(why.into()));
}
let run_params = self.admit(id, spec, &params)?;
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.jobs.spawn = background;
+252 -8
View File
@@ -12,8 +12,11 @@
//! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the
//! command's main key, unless one of its keys is already the command's default (Zoom In lists
//! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is
//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys are not
//! imported: the tool letters are fixed here.
//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys match our
//! tools by name ([`plan_tools`]; the app knows the tools). A set saved on Windows spells the
//! primary modifier Ctrl where a Mac set says Cmd ([`KysSet::windows`], [`to_ours`]).
//! [`write`] saves a set the same way, with our command id on each row (`pc-id`, which
//! Photoshop ignores), so PhotoCraft reads its own sets back exactly.
use std::collections::{BTreeMap, HashMap};
@@ -23,10 +26,12 @@ use crate::prefs::normalize_shortcut;
pub const MAX_BYTES: usize = 4 << 20;
/// One `<command>` row: Photoshop's label and its shortcuts, first one primary.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct KysCommand {
pub name: String,
pub shortcuts: Vec<String>,
/// Our command id, in sets PhotoCraft wrote ([`write`]'s `pc-id`): matched before the label.
pub id: Option<String>,
}
/// The parsed set: its name, its command rows and how many tools carry a key.
@@ -37,6 +42,11 @@ pub struct KysSet {
pub name: Option<String>,
pub commands: Vec<KysCommand>,
pub tool_keys: usize,
/// The `<tool>` rows with a key: the tool's name, its key, our id in sets PhotoCraft wrote.
pub tools: Vec<KysCommand>,
/// Saved by Photoshop on Windows: no key spells ⌘ or Opt, and Ctrl is the primary modifier,
/// so `Ctrl` here is our `Cmd` (a Mac set's `Control` stays the Control key). See [`to_ours`].
pub windows: bool,
}
/// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows
@@ -63,16 +73,62 @@ pub fn parse(text: &str) -> Result<KysSet, String> {
.filter_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string))
.collect();
if !shortcuts.is_empty() {
set.commands.push(KysCommand { name: name.to_string(), shortcuts });
let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string);
set.commands.push(KysCommand { name: name.to_string(), shortcuts, id });
}
}
"tool" => {
let key = node.text().map(str::trim).filter(|t| !t.is_empty());
let name = node.attribute("name").map(str::trim).filter(|n| !n.is_empty());
if let Some(key) = key {
set.tool_keys += 1;
if let Some(name) = name {
let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string);
set.tools.push(KysCommand { name: name.to_string(), shortcuts: vec![key.to_string()], id });
}
}
}
"tool" if node.text().is_some_and(|t| !t.trim().is_empty()) => set.tool_keys += 1,
_ => {}
}
}
set.windows = windows_set(&set);
Ok(set)
}
/// The modifier words of `sc` (`Ctrl+Alt+K` → Ctrl, Alt), whatever the key is.
fn modifiers(sc: &str) -> impl Iterator<Item = String> + '_ {
let mods = sc.strip_suffix("++").unwrap_or_else(|| sc.rsplit_once('+').map_or("", |(m, _)| m));
mods.split('+').filter(|m| !m.is_empty()).map(|m| m.trim().to_ascii_lowercase())
}
/// Photoshop on Windows writes Ctrl for the primary modifier, where a Mac set writes Cmd (and
/// Control for the Control key): a set with no ⌘ or Opt anywhere but some Ctrl is a Windows set.
fn windows_set(set: &KysSet) -> bool {
let keys = || set.commands.iter().flat_map(|c| c.shortcuts.iter());
let mac = keys().flat_map(|k| modifiers(k).collect::<Vec<_>>()).any(|m| matches!(m.as_str(), "cmd" | "command" | "opt" | "option"));
let ctrl = keys().flat_map(|k| modifiers(k).collect::<Vec<_>>()).any(|m| matches!(m.as_str(), "ctrl" | "control"));
!mac && ctrl
}
/// A key of `set` in our notation, normalised: on a Windows set, Ctrl (or Control) is our `Cmd`
/// (Ctrl on Windows and Linux, ⌘ on the Mac), so `Ctrl+L` is the `Cmd+L` default rather than a
/// literal Control key.
pub fn to_ours(set: &KysSet, sc: &str) -> Option<String> {
if !set.windows {
return normalize_shortcut(sc);
}
let (mods, key) = match sc.strip_suffix("++") {
Some(m) => (m, "+"),
None => sc.rsplit_once('+').unwrap_or(("", sc)),
};
let mods: Vec<&str> = mods
.split('+')
.filter(|m| !m.is_empty())
.map(|m| if matches!(m.trim().to_ascii_lowercase().as_str(), "ctrl" | "control") { "Cmd" } else { m })
.collect();
normalize_shortcut(&mods.into_iter().chain(std::iter::once(key)).collect::<Vec<_>>().join("+"))
}
/// What an import would change.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
@@ -124,18 +180,21 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
entry.push((c.id, c.default));
}
}
let by_id: HashMap<&str, &(String, Option<String>)> = by_label.values().flatten().map(|c| (c.0.as_str(), c)).collect();
let mut plan = Plan::default();
for row in &set.commands {
let Some(candidates) = by_label.get(&key(&row.name)) else {
// A set PhotoCraft wrote names the command itself, whatever its label says.
let own = row.id.as_deref().and_then(|id| by_id.get(id)).map(|c| vec![(*c).clone()]);
let Some(candidates) = own.as_ref().or_else(|| by_label.get(&key(&row.name))) else {
plan.unknown.push(row.name.clone());
continue;
};
let Some(sc) = row.shortcuts.first().and_then(|s| normalize_shortcut(s)) else {
let Some(sc) = row.shortcuts.first().and_then(|s| to_ours(set, s)) else {
plan.unreadable.push(row.name.clone());
continue;
};
plan.alternates += row.shortcuts.len().saturating_sub(1);
let imported: Vec<String> = row.shortcuts.iter().filter_map(|s| normalize_shortcut(s)).collect();
let imported: Vec<String> = row.shortcuts.iter().filter_map(|s| to_ours(set, s)).collect();
let pick = candidates
.iter()
.find(|(_, d)| d.as_deref().and_then(normalize_shortcut).is_some_and(|d| imported.contains(&d)))
@@ -159,6 +218,92 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
plan
}
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
let mut keys = BTreeMap::new();
let mut unknown = Vec::new();
for row in &set.tools {
let found =
row.id.as_deref().and_then(|id| tools.iter().find(|(t, _)| *t == id)).or_else(|| tools.iter().find(|(_, name)| key(name) == key(&row.name)));
let Some((id, _)) = found else {
unknown.push(row.name.clone());
continue;
};
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
if let (Some(c), None) = (letters.next(), letters.next())
&& c.is_ascii_alphanumeric()
{
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
}
}
(keys, unknown)
}
/// The attribute naming our command id on the rows PhotoCraft writes (Photoshop ignores it).
pub const PC_ID: &str = "pc-id";
/// One row to write: a command (or tool), its label and its keys, main key first.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub id: String,
pub label: String,
pub tool: bool,
pub keys: Vec<String>,
}
/// A key in the spelling Photoshop's macOS sets use (`Control+Opt+Shift+Cmd+G`): our `Ctrl` is
/// Control, `Alt` is Opt. `None` for a key that isn't one.
pub fn photoshop_key(sc: &str) -> Option<String> {
let n = normalize_shortcut(sc)?;
// The key is the last part; `Cmd++` ends in an empty part, so take it from the string.
let (mods, key) = match n.strip_suffix("++") {
Some(m) => (m, "+"),
None => n.rsplit_once('+').unwrap_or(("", n.as_str())),
};
let has = |m: &str| mods.split('+').any(|x| x == m);
let mut out: Vec<&str> =
[("Ctrl", "Control"), ("Alt", "Opt"), ("Shift", "Shift"), ("Cmd", "Cmd")].iter().filter(|(ours, _)| has(ours)).map(|(_, ps)| *ps).collect();
out.push(key);
Some(out.join("+"))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&apos;")
}
/// A `.kys` file for the set `name` (Edit › Keyboard Shortcuts › Save Set): every entry with a
/// key, commands then tools, labels with Photoshop's `...`, keys in [`photoshop_key`] spelling,
/// and our id on each row so PhotoCraft reads its own sets back exactly. Rows without a key are
/// left out, and a key that isn't one is skipped.
pub fn write(name: &str, entries: &[Entry]) -> String {
let name = xml_escape(name.trim());
let mut out = format!(
"\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<photoshop-keyboard-shortcuts version=\"4\" filename=\"$$$/FileName/Presets/KeyboardCustomization/{name}={name}\">\n"
);
let row = |e: &Entry| -> Option<String> {
let keys: Vec<String> = e.keys.iter().filter_map(|k| photoshop_key(k)).collect();
let first = keys.first()?;
let label = xml_escape(&e.label.replace('…', "..."));
let id = xml_escape(&e.id);
Some(if e.tool {
format!("\t<tool name=\"{label}\" {PC_ID}=\"{id}\">{}</tool>\n", xml_escape(first))
} else {
let shortcuts: String = keys.iter().map(|k| format!("<shortcut>{}</shortcut>", xml_escape(k))).collect();
format!("\t<command kind=\"static\" name=\"{label}\" {PC_ID}=\"{id}\">{shortcuts}</command>\n")
})
};
for e in entries.iter().filter(|e| !e.tool).chain(entries.iter().filter(|e| e.tool)) {
if let Some(r) = row(e) {
out.push_str(&r);
}
}
out.push_str("</photoshop-keyboard-shortcuts>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,4 +356,103 @@ mod tests {
assert!(parse(&big).unwrap_err().contains("4 MiB"));
assert!(parse("<other/>").unwrap_err().contains("not a keyboard shortcut set"));
}
#[test]
fn written_sets_read_back_exactly_and_keys_use_photoshops_spelling() {
assert_eq!(photoshop_key("Cmd+Alt+Shift+Ctrl+g").as_deref(), Some("Control+Opt+Shift+Cmd+G"));
assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++"));
assert_eq!(photoshop_key("F3").as_deref(), Some("F3"));
assert_eq!(photoshop_key("Cmd+Shift+Ctrl"), None);
let entries = vec![
Entry { id: "edit.copy".into(), label: "Copy".into(), tool: false, keys: vec!["Cmd+C".into(), "F3".into()] },
Entry { id: "x.odd".into(), label: "Levels…".into(), tool: false, keys: vec!["Cmd+Alt+L".into()] },
Entry { id: "tools.select.move".into(), label: "Move Tool".into(), tool: true, keys: vec!["V".into()] },
Entry { id: "none".into(), label: "Unbound".into(), tool: false, keys: vec![] },
Entry { id: "bad".into(), label: "A <\"weird\"> & 'name'".into(), tool: false, keys: vec!["Cmd+Shift+Ctrl".into(), "F9".into()] },
];
let xml = write(" My & Set ", &entries);
assert!(xml.contains("KeyboardCustomization/My &amp; Set=My &amp; Set"), "{xml}");
assert!(xml.contains("<command kind=\"static\" name=\"Levels...\" pc-id=\"x.odd\"><shortcut>Opt+Cmd+L</shortcut></command>"), "{xml}");
assert!(!xml.contains("Unbound"), "{xml}");
assert!(xml.find("Move Tool").unwrap() > xml.find("Levels").unwrap(), "tools after commands");
let set = parse(&xml).unwrap();
assert_eq!(set.name.as_deref(), Some("My & Set"));
assert_eq!(set.tool_keys, 1);
assert_eq!(set.commands.len(), 3);
assert_eq!(set.commands[0], KysCommand { name: "Copy".into(), shortcuts: vec!["Cmd+C".into(), "F3".into()], id: Some("edit.copy".into()) });
assert_eq!(set.commands[2].name, "A <\"weird\"> & 'name'");
assert_eq!(set.commands[2].shortcuts, vec!["F9".to_string()]);
// The id wins over the label: a renamed or translated label still finds its command.
let cands = || {
vec![
Candidate { id: "x.odd".into(), label: "Something Else".into(), default: None },
Candidate { id: "y.levels".into(), label: "Levels".into(), default: Some("Cmd+L".into()) },
]
};
let p = plan(cands(), &set);
assert_eq!(p.set.get("x.odd").map(String::as_str), Some("Cmd+Alt+L"));
assert!(!p.set.contains_key("y.levels"));
// Without the id, the label matches as before.
let mut plain = set.clone();
plain.commands[1].id = None;
assert!(plan(cands(), &plain).set.contains_key("y.levels"));
}
#[test]
fn a_windows_set_means_cmd_by_ctrl_and_a_mac_set_keeps_control() {
let win = "<photoshop-keyboard-shortcuts version=\"4\">
\t<command name=\"Levels...\"><shortcut>Ctrl+L</shortcut></command>
\t<command name=\"Gaussian Blur...\"><shortcut>Ctrl+Alt+Shift+G</shortcut></command>
\t<command name=\"Zoom In\"><shortcut>Ctrl++</shortcut><shortcut>Ctrl+=</shortcut></command>
</photoshop-keyboard-shortcuts>";
let set = parse(win).unwrap();
assert!(set.windows);
assert_eq!(to_ours(&set, "Ctrl+L").as_deref(), Some("Cmd+L"));
assert_eq!(to_ours(&set, "Ctrl+Alt+Shift+G").as_deref(), normalize_shortcut("Cmd+Alt+Shift+G").as_deref());
assert_eq!(to_ours(&set, "Ctrl++").as_deref(), Some("Cmd++"));
assert_eq!(to_ours(&set, "F3").as_deref(), Some("F3"));
let cands = vec![Candidate { id: "image.levels".into(), label: "Levels…".into(), default: Some("Cmd+L".into()) }];
assert_eq!(plan(cands, &set).set.get("image.levels").map(String::as_str), Some("Cmd+L"), "the default, not a Control override");
// A Mac set spells ⌘ Cmd: its Control is the Control key.
let mac = parse(
"<photoshop-keyboard-shortcuts version=\"4\"><command name=\"Levels...\"><shortcut>Cmd+L</shortcut></command><command name=\"X\"><shortcut>Control+Opt+Cmd+G</shortcut></command><command name=\"Y\"><shortcut>Control+K</shortcut></command></photoshop-keyboard-shortcuts>",
)
.unwrap();
assert!(!mac.windows);
assert_eq!(to_ours(&mac, "Control+K"), normalize_shortcut("Ctrl+K"));
// No modifiers at all: nothing to tell, nothing to change.
let plain = parse("<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F3</shortcut></command></photoshop-keyboard-shortcuts>").unwrap();
assert!(!plain.windows);
}
#[test]
fn tool_keys_match_by_name_or_id_and_take_one_letter() {
let xml = "<photoshop-keyboard-shortcuts version=\"4\">
\t<tool name=\"Move Tool\" type=\"1\">V</tool>
\t<tool name=\"pencil tool\" type=\"1\">n</tool>
\t<tool name=\"Renamed\" pc-id=\"tools.select.brush\">B</tool>
\t<tool name=\"Imaginary Tool\">Q</tool>
\t<tool name=\"Lasso Tool\">Shift+L</tool>
\t<tool name=\"Default Colors\"></tool>
\t<tool>Z</tool>
</photoshop-keyboard-shortcuts>";
let set = parse(xml).unwrap();
assert_eq!((set.tool_keys, set.tools.len()), (6, 5), "a key without a name counts but can't be matched");
let tools = [
("tools.select.move", "Move Tool"),
("tools.select.pencil", "Pencil Tool"),
("tools.select.brush", "Brush Tool"),
("tools.select.lasso", "Lasso Tool"),
];
let (keys, unknown) = plan_tools(tools, &set);
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
// Our own export reads back by id.
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
let (keys, _) = plan_tools(tools, &parse(&out).unwrap());
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("F"));
}
}
+15 -1
View File
@@ -1314,6 +1314,12 @@ fn merge_layers(s: &mut Session) -> Result<Value> {
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
let mut merged = Layer::raster(top.name.clone(), fmt);
merged.locks = top.locks;
merged.label = top.label;
// Clipped layers of one chain merge into a layer still clipped to their base. Clipped
// layers of different chains don't: the result, at the top one's slot, would clip the
// lower chain's pixels to the upper chain's base.
let base = crate::clip_group::base_of(doc, top_id);
merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base);
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt);
let mid = merged.id;
// Hidden selected layers are discarded, as in Photoshop.
@@ -1481,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
link_layers(s)
}),
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
spec!(
"layer.new.groupFromLayers",
"Group from Layers…",
&["Layer", "New"],
None,
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
has_layer,
group_layers
),
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
spec!(
"layer.lockLayers",
+71 -3
View File
@@ -103,6 +103,7 @@ pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod selection_cmds;
pub mod shortcut_sets;
pub mod slice_cmds;
pub mod smart_cmds;
pub mod smartselect_cmds;
@@ -234,6 +235,20 @@ pub struct DocState {
}
impl DocState {
/// Trim the history to its budget, counting the tiles only History panel snapshots hold
/// against it too (#4530). Returns how many states were dropped.
pub fn trim_history(&mut self) -> usize {
let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect();
self.history.trim_with(&self.doc, &snaps)
}
/// Pixel bytes of the document, the tiles only its history holds and the tiles only its
/// History panel snapshots hold.
pub fn memory_bytes(&self) -> usize {
let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect();
self.history.pixel_bytes(&self.doc).saturating_add(self.history.extra_bytes(&self.doc, &snaps))
}
pub fn new(doc: Document, path: Option<String>) -> Self {
let active_layer = doc.top_layer();
let mut history = History::default();
@@ -406,6 +421,12 @@ pub struct CopiedStyle {
pub mode: photocraft_color::ColorMode,
}
/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does
/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
/// or fill layer) is known only when they run.
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -417,8 +438,8 @@ pub struct Session {
coalesce_request: Option<String>,
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
pub clipboard: Option<edit_cmds::Clip>,
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
/// Blending switches.
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options
/// ([`CopiedStyle`]: blend mode, fill opacity, Advanced Blending, Blend If, channels).
pub style_clipboard: Option<CopiedStyle>,
/// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
@@ -644,6 +665,53 @@ impl Session {
self.live_commit = None;
}
/// Check a call without running it: the command exists, its params have the shape
/// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does
/// something first only when the command will go through: Free Transform applies its box
/// before a new layer is made.
///
/// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute`
/// accepts (a test holds the two together). For others it checks only what they share.
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if !(params.is_object() || params.is_null()) {
return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() });
}
crate::param_check::check(spec, params)?;
crate::filters::validate_params(id, params)?;
if id == "layer.new.layer" {
crate::new_layer::parse(params)?;
}
// A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the
// document doesn't have.
if spec.params.contains("\"layer\":id")
&& let Some(l) = commands::explicit_layer(self, params)?
&& self.active().is_none_or(|st| st.doc.layer(l).is_none())
{
return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) });
}
self.admit(id, spec, params).map(|_| ())
}
/// What `dispatch` checks once the params are valid and a floating selection has dropped:
/// the precondition (on a named layer for commands that take one), a job running on the
/// document, a hidden target. The run params (the Channels panel's target filled in) when it
/// may run.
pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result<Value> {
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = hidden_target::refusal(self, id, params) {
return Err(EngineError::Other(why.into()));
}
Ok(run_params)
}
/// Is the command currently runnable? (drives menu enablement)
pub fn is_enabled(&self, id: &str) -> bool {
self.is_enabled_with(id, &Value::Null)
@@ -757,7 +825,7 @@ impl Session {
if key.is_none() || st.coalesce != key || !st.history.can_undo() {
st.history.set_current_layers(prior);
st.history.record(label, before, layers);
st.history.trim(&st.doc);
st.trim_history();
} else {
st.history.set_current_layers(layers);
}
+41 -23
View File
@@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
}
}
/// A New Layer call's params, checked.
pub(crate) struct NewLayer {
clip: bool,
mode: BlendMode,
opacity: f32,
label: LabelColor,
neutral: Option<[f32; 3]>,
below: bool,
name: Option<String>,
}
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
}
/// Check a New Layer call's params without running it (Free Transform applies its box before a
/// new layer only when the new layer can be made).
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
let flag = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
@@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
};
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
}
#[cfg(test)]
+5
View File
@@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
// Merge Down replaces the lower layer, so keep its editing restrictions.
// In particular, a merged Background must remain opaque and position-locked.
merged.locks = lower.locks;
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
// with the lower layer's colour label and link.
merged.clipped = lower.clipped && upper.clipped;
merged.label = lower.label;
merged.link_group = lower.link_group;
merged
}
+33 -3
View File
@@ -938,6 +938,10 @@ pub struct Preferences {
/// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this
/// map load with nothing stored, so every command keeps its defaults.
pub alternate_shortcuts: BTreeMap<String, Vec<String>>,
/// Edit › Keyboard Shortcuts › Set: saved shortcut sets by name (`crate::shortcut_sets`).
pub shortcut_sets: BTreeMap<String, crate::shortcut_sets::ShortcutSet>,
/// The saved set in use ("" for the defaults).
pub shortcut_set: String,
pub menus: MenuCustomization,
pub toolbar: ToolbarCustomization,
/// Edit › Check Spelling: words added to the dictionary ("Add").
@@ -1553,7 +1557,7 @@ impl Session {
for st in &mut self.docs {
st.history.max_states = n;
st.history.max_bytes = bytes;
st.history.trim(&st.doc);
st.trim_history();
}
photocraft_compose::set_effect_cache_budget(budget << 20);
// Without waiting for the system font scan: this runs on the UI thread at launch.
@@ -1837,7 +1841,8 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.keyboardShortcuts";
// Every change lands at once, after all of it validated: a bad key leaves the set as it was.
let mut next = s.prefs().clone();
let mut changed = false;
// A set loads first, so the call's other changes apply on top of it.
let mut changed = crate::shortcut_sets::load(&mut next, p).map_err(|e| bad(cmd, e))?;
if let Some(r) = p.get("reset") {
match r {
Value::Bool(true) => {
@@ -1887,6 +1892,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
"windows": set.windows,
}));
let mut with_import = p.clone();
with_import["set"] = Value::Object(merged);
@@ -1951,6 +1957,8 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
// Then the sets are saved, deleted or switched, from the bindings as they now stand.
changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?;
if changed {
s.edit_prefs(|p| *p = next);
}
@@ -1990,6 +1998,28 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
let conflicts: Vec<Value> =
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts});
if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) {
o.extend(sets);
}
// The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the
// app's dialog writes both with the shell's commands and tools added).
let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() };
if p.get("exportKys").and_then(Value::as_bool) == Some(true) {
let entries: Vec<crate::kys::Entry> =
crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect();
out["kys"] = json!(crate::kys::write(set_name, &entries));
}
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos"));
let mut sections: Vec<crate::shortcut_sets::Section> = Vec::new();
for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) {
match sections.iter_mut().find(|(m, _)| *m == menu) {
Some((_, rows)) => rows.push((label, keys)),
None => sections.push((menu, vec![(label, keys)])),
}
}
out["html"] = json!(crate::shortcut_sets::summarize(set_name, &sections, mac));
}
if let Some(import) = import {
out["import"] = import;
}
@@ -2121,7 +2151,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##,
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
keyboard_shortcuts,
true
),
+347
View File
@@ -0,0 +1,347 @@
//! Edit › Keyboard Shortcuts › Set: named shortcut sets, as in Photoshop. The bindings in use
//! stay in [`Preferences::shortcuts`] / [`Preferences::alternate_shortcuts`] (what dispatch
//! reads); a set is a saved copy of them, `Preferences::shortcut_sets` keyed by name, and
//! `Preferences::shortcut_set` names the one in use ("" for the defaults). The bindings differ
//! from their set after an edit until it is saved ("modified", as Photoshop marks the set).
//! A set saves to and loads from a `.kys` file (`crate::kys::write` / the import), and
//! [`summarize`] writes the HTML table of Photoshop's Summarize button.
//!
//! `edit.keyboardShortcuts` takes the set parameters: `useSet` (load a set's bindings, before
//! the call's other changes), then `saveSet` (save the bindings as they end up under a name and
//! use it), `deleteSet`, `sets` (replace every saved set: the dialog's OK) and `activeSet`.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::prefs::{Preferences, alternate_keys, normalize_shortcut};
/// The built-in set's name: the default bindings.
pub const DEFAULTS_NAME: &str = "PhotoCraft Defaults";
/// Saved sets are small; the cap keeps a hostile settings file or call bounded.
pub const MAX_SETS: usize = 64;
/// Longest set name, in characters.
pub const MAX_NAME: usize = 64;
/// A saved set: the overrides of the default bindings, in the shape the preferences keep them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ShortcutSet {
/// Command id → main key; "" removes the default key.
pub shortcuts: BTreeMap<String, String>,
/// Command id → further keys, replacing the defaults.
pub alternate_shortcuts: BTreeMap<String, Vec<String>>,
}
impl ShortcutSet {
/// The bindings `p` uses now.
pub fn of(p: &Preferences) -> Self {
Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() }
}
/// Use this set's bindings in `p`.
pub fn load_into(&self, p: &mut Preferences) {
p.shortcuts = self.shortcuts.clone();
p.alternate_shortcuts = self.alternate_shortcuts.clone();
}
/// Check every key (a set from a call or a hand-edited file must not hold garbage).
fn validate(&self) -> Result<(), String> {
for (id, k) in &self.shortcuts {
if !k.is_empty() && normalize_shortcut(k).is_none() {
return Err(format!("`{k}` for `{id}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)"));
}
}
for (id, keys) in &self.alternate_shortcuts {
alternate_keys(&json!(keys)).map_err(|e| format!("`{id}`: {e}"))?;
}
Ok(())
}
}
/// A set name as given, trimmed; an error for an empty, overlong or reserved name.
pub fn set_name(name: &str) -> Result<String, String> {
let n = name.trim();
if n.is_empty() {
return Err("a set needs a name".into());
}
if n.chars().count() > MAX_NAME {
return Err(format!("a set name is at most {MAX_NAME} characters"));
}
if n.chars().any(char::is_control) {
return Err("a set name can't contain control characters".into());
}
if n.eq_ignore_ascii_case(DEFAULTS_NAME) {
return Err(format!("`{DEFAULTS_NAME}` is the built-in set"));
}
Ok(n.to_string())
}
/// The saved copy of the set in use: `None` for the defaults (or a set that no longer exists).
fn saved(p: &Preferences) -> Option<&ShortcutSet> {
p.shortcut_sets.get(&p.shortcut_set)
}
/// Do the bindings differ from the set in use?
pub fn modified(p: &Preferences) -> bool {
let now = ShortcutSet::of(p);
match saved(p) {
Some(s) => *s != now,
None => now != ShortcutSet::default(),
}
}
/// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the
/// defaults) and makes it the one in use. Returns whether it changed anything.
pub fn load(next: &mut Preferences, p: &Value) -> Result<bool, String> {
let Some(v) = p.get("useSet") else { return Ok(false) };
let name = v.as_str().ok_or("`useSet` is a set name")?.trim();
if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) {
ShortcutSet::default().load_into(next);
next.shortcut_set = String::new();
} else {
let set = next.shortcut_sets.get(name).cloned().ok_or_else(|| format!("no shortcut set named `{name}`"))?;
set.load_into(next);
next.shortcut_set = name.to_string();
}
Ok(true)
}
/// After the call's binding changes: `sets` (replace every saved set), `deleteSet`, `saveSet`
/// (save the bindings under a name and use it) and `activeSet`. Returns whether it changed
/// anything.
pub fn store(next: &mut Preferences, p: &Value) -> Result<bool, String> {
let mut changed = false;
if let Some(v) = p.get("sets") {
let obj = v.as_object().ok_or("`sets` maps set names to {shortcuts, alternateShortcuts}")?;
if obj.len() > MAX_SETS {
return Err(format!("at most {MAX_SETS} shortcut sets"));
}
let mut sets = BTreeMap::new();
for (name, s) in obj {
let name = set_name(name)?;
let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?;
set.validate().map_err(|e| format!("set `{name}`: {e}"))?;
sets.insert(name, set);
}
next.shortcut_sets = sets;
changed = true;
}
if let Some(v) = p.get("deleteSet") {
let name = v.as_str().ok_or("`deleteSet` is a set name")?.trim();
if next.shortcut_sets.remove(name).is_none() {
return Err(format!("no shortcut set named `{name}`"));
}
if next.shortcut_set == name {
next.shortcut_set = String::new();
}
changed = true;
}
if let Some(v) = p.get("saveSet") {
let name = set_name(v.as_str().ok_or("`saveSet` is a set name")?)?;
if !next.shortcut_sets.contains_key(&name) && next.shortcut_sets.len() >= MAX_SETS {
return Err(format!("at most {MAX_SETS} shortcut sets"));
}
next.shortcut_sets.insert(name.clone(), ShortcutSet::of(next));
next.shortcut_set = name;
changed = true;
}
if let Some(v) = p.get("activeSet") {
let name = v.as_str().ok_or("`activeSet` is a set name")?.trim();
if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) {
next.shortcut_set = String::new();
} else if next.shortcut_sets.contains_key(name) {
next.shortcut_set = name.to_string();
} else {
return Err(format!("no shortcut set named `{name}`"));
}
changed = true;
}
Ok(changed)
}
/// The sets, the one in use and whether the bindings differ from it, for the command's reply.
pub fn describe(p: &Preferences) -> Value {
json!({
"sets": p.shortcut_sets.keys().collect::<Vec<_>>(),
"activeSet": if saved(p).is_some() { p.shortcut_set.as_str() } else { "" },
"defaultsName": DEFAULTS_NAME,
"modified": modified(p),
})
}
/// A key as the Summarize table shows it on `mac` (⌘ spelled Cmd, ⌥ Option) or elsewhere
/// (Ctrl, Alt).
fn key_label(sc: &str, mac: bool) -> String {
let Some(n) = normalize_shortcut(sc) else { return sc.to_string() };
// `Cmd++` splits into an empty part before the key, which joins back as `++`.
let mut parts: Vec<&str> = Vec::new();
for part in n.split('+') {
let shown = match (part, mac) {
("Ctrl", true) => "Control",
("Cmd", false) | ("Ctrl", false) => "Ctrl",
("Alt", true) => "Option",
(p, _) => p,
};
// Off the Mac, ⌘ and Control are both Ctrl: say it once.
if shown == "Ctrl" && parts.contains(&"Ctrl") {
continue;
}
parts.push(shown);
}
parts.join("+")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// A Summarize row: a command's label and its keys, the main one first.
pub type Row = (String, Vec<String>);
/// A Summarize section: its title and rows.
pub type Section = (String, Vec<Row>);
/// Photoshop's Summarize: an HTML page listing every command with a key, by section
/// (`sections` in display order: section title, then (command label, keys with the main one
/// first)). Commands without a key are left out.
pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
let mut out = String::new();
out.push_str("<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><title>");
out.push_str(&html_escape(set));
out.push_str("</title>\n<style>body{font-family:sans-serif;margin:2em}table{border-collapse:collapse;margin-bottom:2em}th,td{border:1px solid #bbb;padding:4px 10px;text-align:left}th{background:#eee}</style>\n</head><body>\n<h1>");
out.push_str(&html_escape(set));
out.push_str("</h1>\n");
for (title, rows) in sections {
let rows: Vec<&Row> = rows.iter().filter(|(_, keys)| !keys.is_empty()).collect();
if rows.is_empty() {
continue;
}
out.push_str("<h2>");
out.push_str(&html_escape(title));
out.push_str("</h2>\n<table><tr><th>Command</th><th>Shortcut</th></tr>\n");
for (label, keys) in rows {
let keys: Vec<String> = keys.iter().map(|k| html_escape(&key_label(k, mac))).collect();
out.push_str("<tr><td>");
out.push_str(&html_escape(label));
out.push_str("</td><td>");
out.push_str(&keys.join(", "));
out.push_str("</td></tr>\n");
}
out.push_str("</table>\n");
}
out.push_str("</body></html>\n");
out
}
/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id,
/// label, top-level menu, keys with the main one first). The app's dialog adds the commands
/// only the shell knows (View › Zoom In, the tools).
pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec<String>)> {
let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2)));
crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut))
.chain(temporary)
.filter_map(|(id, label, menu, def)| {
let main = p.shortcut(id, def);
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
keys.extend(p.alternates(id, main));
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn prefs() -> Preferences {
Preferences::default()
}
#[test]
fn save_use_modify_and_delete_a_set() {
let mut p = prefs();
assert!(!modified(&p));
p.shortcuts.insert("edit.copy".into(), "F9".into());
assert!(modified(&p), "an edit to the defaults");
assert!(store(&mut p, &json!({"saveSet": " Mine "})).unwrap());
assert_eq!((p.shortcut_set.as_str(), modified(&p)), ("Mine", false));
p.shortcuts.insert("edit.cut".into(), "F8".into());
assert!(modified(&p));
// Back to the defaults, then to the saved set as it was saved.
assert!(load(&mut p, &json!({"useSet": ""})).unwrap());
assert!(p.shortcuts.is_empty() && p.shortcut_set.is_empty() && !modified(&p));
load(&mut p, &json!({"useSet": "Mine"})).unwrap();
assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F9"));
assert_eq!(p.shortcuts.get("edit.cut"), None, "the unsaved edit isn't in the set");
assert_eq!(describe(&p)["sets"], json!(["Mine"]));
// Deleting the set in use falls back to the defaults' name; the bindings stay.
store(&mut p, &json!({"deleteSet": "Mine"})).unwrap();
assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty());
assert_eq!(describe(&p)["activeSet"], "");
assert!(modified(&p), "F9 differs from the defaults");
}
#[test]
fn bad_names_sets_and_params_are_errors() {
let mut p = prefs();
for name in ["", " ", "PhotoCraft defaults", "a\u{7}b", &"x".repeat(65)] {
assert!(store(&mut p, &json!({"saveSet": name})).is_err(), "{name:?}");
}
assert!(load(&mut p, &json!({"useSet": "Nope"})).is_err());
assert!(load(&mut p, &json!({"useSet": 3})).is_err());
assert!(store(&mut p, &json!({"deleteSet": "Nope"})).is_err());
assert!(store(&mut p, &json!({"activeSet": "Nope"})).is_err());
assert!(store(&mut p, &json!({"sets": []})).is_err());
assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}})).is_err(), "no key");
let nine: Vec<String> = (1..=9).map(|i| format!("F{i}")).collect();
assert!(store(&mut p, &json!({"sets": {"A": {"alternateShortcuts": {"edit.copy": nine}}}})).is_err());
assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": 5}}})).is_err());
let many: serde_json::Map<String, Value> = (0..=MAX_SETS).map(|i| (format!("S{i}"), json!({}))).collect();
assert!(store(&mut p, &json!({ "sets": many })).is_err());
for i in 0..MAX_SETS {
store(&mut p, &json!({"saveSet": format!("S{i}")})).unwrap();
}
assert!(store(&mut p, &json!({"saveSet": "One more"})).is_err());
assert!(store(&mut p, &json!({"saveSet": "S3"})).is_ok(), "saving over a set is fine at the cap");
}
#[test]
fn sets_replace_all_and_active_set_follows() {
let mut p = prefs();
store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "F9"}}, "B": {}}, "activeSet": "A"})).unwrap();
assert_eq!(p.shortcut_set, "A");
assert!(modified(&p), "activeSet names the set; it doesn't load it");
load(&mut p, &json!({"useSet": "A"})).unwrap();
assert!(!modified(&p));
store(&mut p, &json!({"activeSet": "PhotoCraft Defaults"})).unwrap();
assert_eq!(p.shortcut_set, "");
}
#[test]
fn summarize_lists_bound_commands_by_section_for_each_platform() {
let sections = vec![
("Edit".to_string(), vec![("Copy".to_string(), vec!["Cmd+C".to_string(), "F3".to_string()]), ("Unbound".to_string(), vec![])]),
("Empty".to_string(), vec![("Nothing".to_string(), vec![])]),
("Filter <x>".to_string(), vec![("Gaussian Blur & more".to_string(), vec!["Ctrl+Alt+Cmd+G".to_string()])]),
];
let mac = summarize("My <Set>", &sections, true);
assert!(mac.contains("<title>My &lt;Set&gt;</title>") && mac.contains("<td>Copy</td><td>Cmd+C, F3</td>"), "{mac}");
assert!(mac.contains("Cmd+Control+Option+G") && mac.contains("Gaussian Blur &amp; more") && mac.contains("Filter &lt;x&gt;"), "{mac}");
assert!(!mac.contains("Unbound") && !mac.contains("Empty"), "{mac}");
let pc = summarize("S", &sections, false);
assert!(pc.contains("<td>Ctrl+C, F3</td>") && pc.contains("Ctrl+Alt+G"), "{pc}");
assert_eq!(key_label("Cmd++", true), "Cmd++");
}
#[test]
fn bound_lists_main_keys_then_alternates() {
let p = prefs();
let b = bound(&p);
let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand"));
}
}
+2 -36
View File
@@ -298,40 +298,6 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
out
}
/// Mixes `filtered` over `base` through the filter mask (premultiplied, any channel layout).
fn mask_mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface {
let fmt = base.format();
let area = base.content_bounds().union(&filtered.content_bounds());
let mut out = filtered.clone();
if area.is_empty() {
return out;
}
let n = fmt.channels();
let (b, f) = (base.read_region(area), filtered.read_region(area));
let mut k = Vec::new();
mask.values_into(area, &mut k);
let mut o = vec![0.0f32; b.len()];
for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() {
let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0);
if !fmt.alpha {
for c in 0..n {
op[c] = bp[c] + (fp[c] - bp[c]) * m;
}
continue;
}
let a = n - 1;
let oa = bp[a] + (fp[a] - bp[a]) * m;
op[a] = oa;
for c in 0..a {
let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m;
op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 };
}
}
out.write_region(area, &o);
out.prune();
out
}
/// Runs the smart filter stack on the placed (unfiltered) content.
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface {
if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) {
@@ -344,7 +310,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
cur = apply_smart_filter(f, &cur, canvas, extent);
}
match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Some(m) => mask_mix(placed, &cur, m),
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
None => cur,
}
}
@@ -449,7 +415,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
}
ctx.progress(1.0, "");
Ok(Some(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Some(m) => mask_mix(&placed()?.surface, &cur.surface, m),
Some(m) => photocraft_compose::filter_mask::mix(&placed()?.surface, &cur.surface, m),
None => cur.surface,
}))
}
+2 -7
View File
@@ -422,10 +422,7 @@ pub fn render_video_with(
let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1);
// Loaded timelines bypass the duration validation used by timeline.create/setProps.
if frames > crate::timeline_cmds::MAX_DURATION {
return Err(EngineError::Other(format!(
"the timeline is {frames} frames; Render Video is limited to {} frames",
crate::timeline_cmds::MAX_DURATION
)));
return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION)));
}
let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0);
let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string();
@@ -443,9 +440,7 @@ pub fn render_video_with(
// The whole canvas is staged as f32 RGBA for each frame; reject over-budget
// dimensions before an infallible allocation can abort the process.
if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS {
return Err(EngineError::Other(format!(
"{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"
)));
return Err(EngineError::Other(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)")));
}
let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16;
let mut gframes = Vec::with_capacity(frames);
+75
View File
@@ -0,0 +1,75 @@
//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers,
//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B).
use photocraft_engine::Session;
use serde_json::json;
fn stack() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let mut ids = [0u64; 3];
for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() {
ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": color})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
}
for c in &ids[1..] {
s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap();
}
(s, ids)
}
fn clipped(s: &Session, id: u64) -> bool {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped
}
#[test]
fn releasing_a_middle_layer_releases_the_ones_above_it() {
let (mut s, [_, c1, c2]) = stack();
let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}");
// Releasing the top one leaves the one below it alone.
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap();
assert!(clipped(&s, c1) && !clipped(&s, c2));
}
#[test]
fn merging_clipped_layers_keeps_the_result_clipped() {
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c2})).unwrap();
let r = s.execute("layer.mergeDown", json!({})).unwrap();
let merged = r["layer"].as_u64().unwrap_or(c1);
assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B");
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped");
// Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge
// into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E.
let (mut s, [_, c1, e]) = stack();
s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap();
let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains");
// Merging the base with its clipped layers gives an ordinary layer.
let (mut s, [b, _, c2]) = stack();
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()));
}
#[test]
fn deleting_a_hidden_base_releases_its_clipped_layers() {
let (mut s, [b, c1, c2]) = stack();
s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap();
s.execute("layer.delete.hiddenLayers", json!({})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base");
}
@@ -0,0 +1,72 @@
//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in
//! Photoshop), and Photoshop's History names for each kind of change.
use photocraft_engine::Session;
use serde_json::json;
fn doc() -> (Session, Vec<u64>) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect();
(s, ids)
}
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
}
fn last_step(s: &Session) -> String {
s.active().unwrap().history.entries().last().cloned().unwrap_or_default()
}
#[test]
fn one_change_reaches_every_listed_layer_in_one_step() {
let (mut s, ids) = doc();
let steps = s.active().unwrap().history.entries().len();
s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap();
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step");
assert_eq!(last_step(&s), "Opacity Change");
assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4));
s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap();
assert_eq!(last_step(&s), "Blending Change");
assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply));
s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap();
assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless");
s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap();
assert_eq!(last_step(&s), "Fill Opacity Change");
s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap();
assert_eq!(last_step(&s), "Rename Layer");
s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap();
assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name");
}
#[test]
fn bad_layer_lists_change_nothing() {
let (mut s, ids) = doc();
let before = s.active().unwrap().doc.clone();
for p in [
json!({"layers": [], "opacity": 0.5}),
json!({"layers": "all", "opacity": 0.5}),
json!({"layers": ["x"], "opacity": 0.5}),
json!({"layers": [ids[0], 999_999], "opacity": 0.5}),
json!({"layers": [ids[0], ids[1]], "name": "Both"}),
json!({"layers": [ids[0], ids[1]], "opacity": 2.0}),
] {
assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}");
assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}");
}
}
#[test]
fn pass_through_is_for_groups_only() {
let (mut s, ids) = doc();
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
// A pixel layer, alone or with a group, can't take Pass Through; nothing changes.
assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err());
let before = (layer(&s, ids[0]).blend, layer(&s, g).blend);
assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err());
assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before);
s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap();
s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap();
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
}
+74
View File
@@ -0,0 +1,74 @@
//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
use photocraft_engine::{PRECHECKED, Session};
use serde_json::{Value, json};
/// Documents in different states: a Background only, a pixel layer active, a group active, a
/// selection, no document.
fn states() -> Vec<(&'static str, Session)> {
let mut out = Vec::new();
out.push(("no document", Session::new()));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
out.push(("background only", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
out.push(("pixel layer", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
s.execute("layer.new.group", json!({})).unwrap();
out.push(("group, no background", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
out.push(("selection", s));
out
}
fn params() -> Vec<Value> {
vec![
Value::Null,
json!({}),
json!([1]),
json!("x"),
json!({"mode": "Bogus"}),
json!({"mode": "Multiply"}),
json!({"fillNeutral": true}),
json!({"mode": "Overlay", "fillNeutral": true}),
json!({"position": "x"}),
json!({"position": "below"}),
json!({"opacity": "half"}),
json!({"name": 5}),
json!({"name": "ok"}),
json!({"color": "chartreuse"}),
json!({"layer": 999_999}),
json!({"layer": "Layer 1"}),
json!({"layer": "no such layer"}),
json!({"layer": true}),
json!({"ifPresent": true}),
]
}
#[test]
fn precheck_agrees_with_execute() {
let mut disagreements = Vec::new();
for id in PRECHECKED {
for p in params() {
for (state, _) in states() {
// A fresh copy of the state for each call: execute changes it.
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
let pre = s.precheck(id, &p).is_ok();
let ran = s.execute(id, p.clone()).is_ok();
if pre != ran {
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
}
}
}
}
assert!(disagreements.is_empty(), "{disagreements:#?}");
}
+60
View File
@@ -0,0 +1,60 @@
//! Edit › Keyboard Shortcuts › Set through `edit.keyboardShortcuts`: save, switch, delete,
//! export to `.kys` and back, Summarize, and bad params as errors.
use photocraft_engine::Session;
use serde_json::{Value, json};
fn ks(s: &mut Session, p: Value) -> Value {
s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts")
}
#[test]
fn a_set_saves_switches_exports_and_imports_back() {
let mut s = Session::new();
let r = ks(&mut s, json!({}));
assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["defaultsName"].as_str()), (Some(""), Some(false), Some("PhotoCraft Defaults")));
// Edit, then save the bindings as they end up after this call's edits.
let r = ks(&mut s, json!({"set": {"edit.copy": "F9"}, "alternates": {"edit.copy": []}, "saveSet": "Mine"}));
assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["sets"].clone()), (Some("Mine"), Some(false), json!(["Mine"])));
// Export the set, go back to the defaults, then import the file: the bindings return.
let xml = ks(&mut s, json!({"exportKys": true}))["kys"].as_str().expect("kys").to_string();
assert!(xml.contains("KeyboardCustomization/Mine=Mine") && xml.contains("pc-id=\"edit.copy\"><shortcut>F9</shortcut></command>"), "{xml}");
let r = ks(&mut s, json!({"useSet": ""}));
assert_eq!((r["overrides"].clone(), r["activeSet"].as_str()), (json!({}), Some("")));
let r = ks(&mut s, json!({"reset": true, "importKys": xml}));
assert_eq!(r["overrides"]["edit.copy"], "F9");
assert_eq!(r["alternateOverrides"]["edit.copy"], json!([]), "Copy's F3 stays removed");
assert_eq!(r["modified"], true, "imported over the defaults");
// Switch back to the saved set; a later unsaved edit marks it modified.
ks(&mut s, json!({"useSet": "Mine"}));
let r = ks(&mut s, json!({"set": {"edit.cut": "F8"}}));
assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool()), (Some("Mine"), Some(true)));
// Summarize lists what's bound, per platform.
let html = ks(&mut s, json!({"summarize": {"mac": false}}))["html"].as_str().expect("html").to_string();
assert!(html.contains("<title>Mine</title>") && html.contains("<td>Copy</td><td>F9</td>") && html.contains("<td>Cut</td><td>F8"), "{html}");
// Delete the set in use: the defaults' name, the bindings kept.
let r = ks(&mut s, json!({"deleteSet": "Mine"}));
assert_eq!((r["sets"].clone(), r["activeSet"].as_str(), r["overrides"]["edit.copy"].as_str()), (json!([]), Some(""), Some("F9")));
}
#[test]
fn bad_set_params_change_nothing() {
let mut s = Session::new();
ks(&mut s, json!({"saveSet": "Keep"}));
let before = s.prefs().clone();
for p in [
json!({"useSet": "Missing"}),
json!({"useSet": 7}),
json!({"saveSet": ""}),
json!({"saveSet": "PhotoCraft Defaults"}),
json!({"deleteSet": "Missing"}),
json!({"activeSet": "Missing"}),
json!({"sets": "nope"}),
json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}}),
// A bad key elsewhere in the call keeps the set from being saved too.
json!({"set": {"edit.copy": "Cmd+Shift+Ctrl"}, "saveSet": "New"}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
assert_eq!(s.prefs(), &before, "{p} changed the preferences");
}
}
+55
View File
@@ -615,3 +615,58 @@ fn cs2_plld_only_smart_object_saves_its_new_transform() {
}
assert!(photocraft_engine::smart_cmds::contents_of(&again, photocraft_doc::LayerId(aid)).is_ok(), "the contents are available");
}
/// Photoshop stores a smart object's filtered pixels without its filter mask and mixes the
/// unfiltered ones back in through the mask (`compose::filter_mask`): the layer record we write
/// holds the filtered pixels wherever the mask shows any of the filter, and opening the file
/// mixes them back to what the document showed.
#[test]
fn a_soft_filter_mask_is_stored_the_way_photoshop_stores_it() {
use photocraft_doc::LayerMask;
use photocraft_raster::Surface;
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 4})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
// A mask ramping from hiding the blur (left) to showing it (right).
s.edit("mask", |doc, _| {
let snapshot = doc.clone();
let l = doc.layer_mut(id).unwrap();
let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") };
let mut m = LayerMask::reveal_all();
m.surface = Surface::new(photocraft_color::PixelFormat::GRAY8);
let r = Rect::new(0, 0, W, H);
m.surface.write_region(r, &(0..H).flat_map(|_| (0..W).map(|x| x as f32 / (W - 1) as f32)).collect::<Vec<_>>());
sm.filter_mask = Some(m);
refresh_layer(&snapshot, l).map(|_| ())
})
.unwrap();
let doc = s.active().unwrap().doc.clone();
let (_, sm) = smart(&doc);
let mixed = sm.cache.clone().unwrap();
// The same smart object with the mask switched off: filtered everywhere.
let mut unmasked = sm.clone();
unmasked.filter_mask.as_mut().unwrap().enabled = false;
let filtered = photocraft_engine::smart_cmds::render(&doc, &unmasked).unwrap().unwrap();
let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap();
let name = doc.layer(id).unwrap().name.clone();
let record = (0..file.layers().len()).filter_map(|i| file.layer(i)).find(|l| l.name() == name).unwrap();
let img = record.rgba8().unwrap();
// A column where the mask shows about half the blur, over opaque content.
let (x, y) = (W / 2, 20);
let at = (((y - img.top) as u32 * img.width + (x - img.left) as u32) * 4) as usize;
let stored = &img.data[at..at + 4];
let want = filtered.pixel(x, y);
let half = mixed.pixel(x, y);
let gap = (0..3).map(|c| (want[c] - half[c]).abs()).fold(0.0f32, f32::max);
assert!(gap > 3.0 / 255.0, "the mask's mix must show at ({x}, {y}) for this test to mean anything");
for c in 0..3 {
assert!((f32::from(stored[c]) / 255.0 - want[c]).abs() <= 1.5 / 255.0, "channel {c}: stored {stored:?}, filtered {want:?}, mixed {half:?}");
}
// Opening the file mixes the unfiltered pixels back in: it shows what the document showed.
let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document;
let d = max_diff(&photocraft_compose::flatten(&back).px, &photocraft_compose::flatten(&doc).px);
assert!(d <= 2.0 / 255.0, "re-opened differs by {d}");
}
+16
View File
@@ -97,3 +97,19 @@ fn several_layers_align_to_an_active_selection() {
assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge");
}
}
#[test]
fn a_groups_pass_through_pastes_onto_a_pixel_layer_as_normal() {
let mut s = Session::new();
let (a, _) = new_doc(&mut s, "rgb");
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough);
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": g})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": a})).unwrap();
assert_eq!(layer(&s, a).blend, photocraft_color::BlendMode::Normal, "Pass Through isn't a pixel layer's mode");
// Onto another group it stays Pass Through.
let g2 = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": g2, "blend": "Multiply"})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": g2})).unwrap();
assert_eq!(layer(&s, g2).blend, photocraft_color::BlendMode::PassThrough);
}
+1 -1
View File
@@ -22,7 +22,7 @@ flate2 = { workspace = true }
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
crc32fast = "1.5"
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
ruzstd = "0.9"
ruzstd = "0.9.1"
# Unix directory cleanup uses no-follow descriptor-relative operations; the same
# dependency provides the plain-fsync fallback on Apple file systems.
+54 -14
View File
@@ -579,10 +579,12 @@ pub(crate) fn paste(shape: &mut [f32], region: Rect, r: Rect, v: &[f32]) {
// ---------------------------------------------------------------------------------------------
// Shape identity and damage
pub(crate) type Tiles = HashMap<TileCoord, Arc<Tile>>;
/// A shape source's tiles by address: a [`std::sync::Weak`] reserves the address (so a new tile
/// can't reuse it and pass for unchanged) without keeping the pixels alive (#4530).
pub(crate) type Tiles = HashMap<TileCoord, std::sync::Weak<Tile>>;
pub(crate) fn snapshot(s: &Surface) -> Tiles {
s.tiles().map(|(c, t)| (*c, t.clone())).collect()
s.tiles().map(|(c, t)| (*c, Arc::downgrade(t))).collect()
}
/// Union of the tiles that differ between `old` and `s`.
@@ -590,7 +592,7 @@ pub(crate) fn damage(old: &Tiles, s: &Surface) -> Rect {
let mut d = Rect::EMPTY;
let mut add = |r: Rect| d = if d.is_empty() { r } else { d.union(&r) };
for (c, t) in s.tiles() {
if old.get(c).is_none_or(|o| !Arc::ptr_eq(o, t)) {
if old.get(c).is_none_or(|o| !std::ptr::eq(o.as_ptr(), Arc::as_ptr(t))) {
add(c.rect());
}
}
@@ -642,39 +644,42 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 {
h.finish()
}
/// Deep identity of a group (children, their pixels by tile pointer, settings).
pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 {
fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher) {
/// Deep identity of a group (children, their pixels by tile pointer, settings). `pin` sees every
/// tile hashed, so a cache entry can reserve their addresses (see [`Tiles`]).
pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut(&Arc<Tile>)) -> u64 {
type Pin<'a> = &'a mut dyn FnMut(&Arc<Tile>);
fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher, pin: Pin) {
format!("{:?}{:?}", s.format(), s.default_pixel()).hash(h);
for (c, t) in s.tiles() {
pin(t);
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(h);
}
}
fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher) {
fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: Pin) {
l.id.0.hash(h);
(l.visible, l.clipped, l.opacity.to_bits(), l.fill_opacity.to_bits(), format!("{:?}", l.blend)).hash(h);
match &l.content {
LayerContent::Group(g) => {
for c in &g.children {
walk(c, h);
walk(c, h, pin);
}
}
LayerContent::Fill(f) => {
format!("{f:?}").hash(h);
if let Some(c) = &l.fill_cache {
surface_fp(&c.surface, h);
surface_fp(&c.surface, h, pin);
}
}
LayerContent::Adjustment(a) => format!("{a:?}").hash(h),
_ => {
if let Some(s) = l.surface() {
surface_fp(s, h);
surface_fp(s, h, pin);
}
}
}
if let Some(m) = &l.mask {
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h);
surface_fp(&m.surface, h);
surface_fp(&m.surface, h, pin);
}
format!("{:?}{:?}", l.vector_mask, l.effects).hash(h);
h.write_u8(0xfe);
@@ -684,14 +689,14 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 {
match &layer.content {
LayerContent::Group(g) => {
for c in &g.children {
walk(c, &mut h);
walk(c, &mut h, pin);
}
if let Some(m) = &layer.mask {
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h);
surface_fp(&m.surface, &mut h);
surface_fp(&m.surface, &mut h, pin);
}
}
_ => walk(layer, &mut h),
_ => walk(layer, &mut h, pin),
}
h.finish()
}
@@ -874,4 +879,39 @@ mod tests {
let shadow = program(&Effect::DropShadow(s), &light, false);
assert!(shadow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "the shadow spread keeps the chamfer: {:?}", shadow.fields);
}
/// Cached shapes and group keys reserve tile addresses without keeping pixels (#4530):
/// damage still tells a replaced tile from an unchanged one, and a dropped tile's pixels
/// are freed although the entry remembers it.
#[test]
fn shape_tiles_and_group_pins_hold_addresses_not_pixels() {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
let mut s = Surface::new(fmt);
// A ramp, so no two tiles share their pixels.
for x in 0..600 {
let v = x as f32 / 600.0;
s.fill_rect(Rect::new(x, 0, x + 1, 300), &[v, 0.2, 0.9, 1.0]);
}
let first = s.tiles().find(|(c, _)| c.tx == 0 && c.ty == 0).map(|(_, t)| t.clone()).unwrap();
assert_eq!(Arc::strong_count(&first), 2);
let old = snapshot(&s);
assert_eq!(Arc::strong_count(&first), 2, "the snapshot adds no strong reference");
assert!(damage(&old, &s).is_empty());
s.fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.0, 0.0, 1.0]);
let origin = s.tiles().map(|(c, _)| *c).find(|c| c.tx == 0 && c.ty == 0).unwrap();
assert_eq!(damage(&old, &s), origin.rect(), "only the edited tile");
drop(first);
assert!(old.values().any(|w| w.strong_count() == 0), "the replaced tile's pixels are freed");
let child = Layer::new("c", LayerContent::Raster(s));
let n = child.surface().map_or(0, |s| s.tile_count());
let group = Layer::group("g", vec![child]);
let mut pins = Vec::new();
let key = group_key(&group, &GlobalLight::default(), &mut |t| pins.push(Arc::downgrade(t)));
assert_eq!(key, group_key(&group, &GlobalLight::default(), &mut |_| {}), "pinning doesn't change the key");
assert_eq!(pins.len(), n);
drop(group);
assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels");
}
}
+11 -5
View File
@@ -49,7 +49,7 @@ use std::sync::Arc;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_compose::effects::FieldKind;
use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, Pattern};
use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
@@ -303,8 +303,8 @@ struct FxEntry {
shape_key: u64,
/// Tiles the shape was rendered from (content, mask): damage is their difference.
tiles: [Option<fx::Tiles>; 2],
/// Groups: a clone pinning the tile pointers `shape_key` hashes.
_pin: Option<Layer>,
/// Groups: the tiles `shape_key` hashes, by address only (see [`fx::Tiles`]).
_pin: Vec<std::sync::Weak<Tile>>,
/// The shape on the CPU (distance fields are computed from it) and on the GPU.
shape_cpu: Vec<f32>,
shape: Tex,
@@ -1719,7 +1719,7 @@ impl Compositor {
let layer = f.layer;
let canvas = doc.bounds();
let is_group = matches!(layer.content, LayerContent::Group(_));
let shape_key = if is_group { fx::group_key(layer, &doc.global_light) } else { fx::shape_key(layer, canvas) };
let shape_key = if is_group { fx::group_key(layer, &doc.global_light, &mut |_| {}) } else { fx::shape_key(layer, canvas) };
let (content_src, mask_src) = if is_group { (None, None) } else { fx::shape_sources(layer) };
let frame = self.frame;
let (rw, rh) = (region.width(), region.height());
@@ -1767,7 +1767,13 @@ impl Compositor {
region,
shape_key,
tiles: [None, None],
_pin: is_group.then(|| layer.clone()),
_pin: if is_group {
let mut pin = Vec::new();
fx::group_key(layer, &doc.global_light, &mut |t| pin.push(Arc::downgrade(t)));
pin
} else {
Vec::new()
},
shape_cpu: vec![0.0; n],
shape,
fields: HashMap::new(),
+12 -6
View File
@@ -206,6 +206,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
ChannelLayout::Gray | ChannelLayout::GrayA => (ColorMode::Grayscale, ChannelLayout::GrayA),
ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba),
ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA),
ChannelLayout::Lab | ChannelLayout::LabA => (ColorMode::Lab, ChannelLayout::LabA),
};
let (depth, csample) = match img.sample_type() {
CSample::U8 => (SampleType::U8, CSample::U8),
@@ -249,6 +250,7 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample:
let mode = match target_layout {
ChannelLayout::Gray | ChannelLayout::GrayA => ColorMode::Grayscale,
ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk,
ChannelLayout::Lab | ChannelLayout::LabA => ColorMode::Lab,
_ => ColorMode::Rgb,
};
let depth = match csample {
@@ -301,6 +303,8 @@ pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout {
(ColorMode::Grayscale, true) => ChannelLayout::GrayA,
(ColorMode::Cmyk, false) => ChannelLayout::Cmyk,
(ColorMode::Cmyk, true) => ChannelLayout::CmykA,
(ColorMode::Lab, false) => ChannelLayout::Lab,
(ColorMode::Lab, true) => ChannelLayout::LabA,
(_, false) => ChannelLayout::Rgb,
(_, true) => ChannelLayout::Rgba,
}
@@ -316,18 +320,19 @@ pub(crate) fn csample(s: SampleType) -> CSample {
/// Renders the document to a flat codec image (native pixels when possible).
pub fn document_to_image(doc: &Document, warnings: &mut Vec<String>) -> Result<Image, IoError> {
document_to_image_for(doc, warnings, true)
document_to_image_for(doc, warnings, true, false)
}
fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target: bool) -> Result<Image, IoError> {
/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels.
fn document_to_image_for(doc: &Document, warnings: &mut Vec<String>, cmyk_target: bool, lab_target: bool) -> Result<Image, IoError> {
let (w, h) = (doc.size.width, doc.size.height);
let canvas = doc.bounds();
let fmt = doc.pixel_format();
let n = (w as usize) * (h as usize);
// Lab has no flat-format layout here: Lab documents always go through the composite, which
// flattens nothing when there is a single plain layer.
// A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it
// goes through the sRGB composite, which flattens nothing when there is a single plain layer.
let single = single_layer(doc);
let native = single.filter(|_| fmt.mode != ColorMode::Lab);
let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target);
let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
let img = if let Some(s) = native {
// Native path: keep model and depth. The surface's encoded samples are the codec's raw
@@ -526,7 +531,8 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
}
let mut warnings = Vec::new();
let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk());
let mut img = document_to_image_for(doc, &mut warnings, cmyk_target)?;
let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab());
let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(composite_share(format));
if opts.xmp == XmpEmbed::None {
+7 -2
View File
@@ -51,6 +51,7 @@ mod multichannel_map;
mod ora;
pub use ora::losses as ora_losses;
pub mod pattern_map;
pub mod pdf;
mod pdn;
pub mod pixels;
mod psd_export;
@@ -273,13 +274,14 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false });
}
if is_psd(bytes) {
let file = PsdFile::from_bytes(bytes)?;
// A merged image the import reads is decoded with the parse: a ZIP one is inflated once.
let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?;
// Nesting past the document model's cap could never be saved; reject the file with the
// actionable limit.
psd_import::check_group_depth(&file)?;
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(0.05);
let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?;
let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?;
document.name = name.to_string();
text_import::prepare(&mut document);
return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false });
@@ -390,6 +392,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
if ext == "ora" {
return ora::export(doc);
}
if ext == "pdf" {
return pdf::export(doc);
}
if ext == photocraft_format::EXTENSION {
let doc = named_for_file(doc, name_or_ext);
let thumbnail = Some(photocraft_compose::thumbnail(doc.as_ref(), 256));
+5 -3
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Pattern};
use photocraft_geom::Rect;
use photocraft_psd::patterns::{PsdPattern, block_key, mode_channels, parse_pattern_block, write_pattern_block};
use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_key, mode_channels, parse_pattern_block_with, write_pattern_block};
use photocraft_raster::Surface;
use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample};
@@ -110,13 +110,15 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
}
}
/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped).
/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All
/// blocks share one memory budget, so repeating a block can't multiply it.
pub fn from_global_blocks(doc: &Document) -> Vec<Pattern> {
let mut budget = PatternBudget::default();
doc.metadata
.psd_global_blocks
.iter()
.filter(|(_, k, _)| PATTERN_KEYS.contains(k))
.filter_map(|(_, _, d)| parse_pattern_block(d).ok())
.filter_map(|(_, _, d)| parse_pattern_block_with(d, &mut budget).ok())
.flatten()
.filter_map(|p| from_psd(&p))
.collect()
+219
View File
@@ -0,0 +1,219 @@
//! The PDF writer (ISO 32000-1 basics): one raster page per file.
//!
//! A small, self-contained writer used by two paths: File › Save a Copy / Save As › PDF (#3401)
//! through [`export`], and File › Print through [`print_pdf`] (called by the engine, which adds
//! paper geometry, printer marks and labels around the image). One page holds a
//! Flate-compressed image XObject, optional printer marks as vector operators and optional
//! Helvetica text; an embedded ICC profile rides in an ICCBased colour space. Multi-page PDF,
//! layer-preserving PDF and PDF import stay open (FILE-215-10).
use std::io::Write as _;
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_doc::Document;
use crate::{ExportResult, IoError};
/// The image placed on a page: 8-bit interleaved samples, 1 (gray), 3 (RGB) or 4 (CMYK,
/// 1 = full ink), with the profile it was authored in if any.
pub struct PrintImage {
pub width: u32,
pub height: u32,
pub channels: usize,
pub data: Vec<u8>,
pub icc: Option<Vec<u8>>,
}
/// Printer marks around the image.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Marks {
pub corner_crop: bool,
pub center_crop: bool,
pub registration: bool,
}
/// One page: paper (pt), image box (pt, origin bottom-left), marks and texts.
pub struct PrintPage {
pub paper: (f64, f64),
pub image: PrintImage,
pub rect: (f64, f64, f64, f64),
pub marks: Marks,
pub description: Option<String>,
pub label: Option<String>,
}
fn pdf_string(s: &str) -> String {
let mut o = String::from("(");
for c in s.chars() {
match c {
'(' | ')' | '\\' => {
o.push('\\');
o.push(c);
}
c if c.is_ascii() && !c.is_ascii_control() => o.push(c),
_ => o.push('?'),
}
}
o.push(')');
o
}
fn deflate(data: &[u8]) -> Vec<u8> {
// Fast: print PDFs are transient and large; level 1 is ~4× quicker than the default.
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
let _ = e.write_all(data);
e.finish().unwrap_or_default()
}
/// Vector marks around `(x, y, w, h)`.
fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String {
let mut s = String::from("q 0 G 0.3 w\n");
let (gap, len) = (6.0, 18.0);
let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n"));
if m.corner_crop {
for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] {
line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy);
line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len));
}
}
if m.center_crop {
let (mx, my) = (x + w / 2.0, y + h / 2.0);
line(&mut s, mx, y - gap, mx, y - gap - len);
line(&mut s, mx, y + h + gap, mx, y + h + gap + len);
line(&mut s, x - gap, my, x - gap - len, my);
line(&mut s, x + w + gap, my, x + w + gap + len, my);
}
if m.registration {
let r = 5.0;
let off = gap + len / 2.0;
for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] {
// Circle (four Béziers) plus crosshair.
let k = 0.5523 * r;
s.push_str(&format!(
"{:.2} {cy:.2} m {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c S\n",
cx + r, cx + r, cy + k, cx + k, cy + r, cy + r, cx - k, cy + r, cx - r, cy + k, cx - r, cx - r, cy - k, cx - k, cy - r, cy - r, cx + k, cy - r, cx + r, cy - k, cx + r
));
line(&mut s, cx - r * 1.6, cy, cx + r * 1.6, cy);
line(&mut s, cx, cy - r * 1.6, cx, cy + r * 1.6);
}
}
s.push_str("Q\n");
s
}
/// A one-page PDF.
pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
let mut offsets: Vec<usize> = Vec::new();
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let img = &page.image;
let (pw, ph) = page.paper;
let (x, y, w, h) = page.rect;
// 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional).
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
obj(
&mut out,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>"
)
.as_bytes(),
);
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
content.push_str(&marks_ops(page.marks, page.rect));
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
let pad = if any_marks { 30.0 } else { 6.0 };
if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) {
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l)));
}
if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) {
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d)));
}
let c = deflate(content.as_bytes());
let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes();
body.extend_from_slice(&c);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
let device = match img.channels {
1 => "/DeviceGray",
4 => "/DeviceCMYK",
_ => "/DeviceRGB",
};
let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() };
let z = deflate(&img.data);
let mut body = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n",
img.width,
img.height,
z.len()
)
.into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
if let Some(icc) = &img.icc {
let z = deflate(icc);
let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes();
body.extend_from_slice(&z);
body.extend_from_slice(b"\nendstream");
obj(&mut out, &body);
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
for o in &offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
out
}
/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size
/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the
/// warning says so — and the embedded profile travels with the image.
pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0));
if w == 0 || h == 0 {
return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into()));
}
let device = match doc.mode {
ColorMode::Rgb => 3,
ColorMode::Grayscale => 1,
ColorMode::Cmyk => 4,
m => return Err(IoError::Unsupported(format!("PDF saves RGB, Grayscale and CMYK documents; {m:?} needs converting to RGB first (Image > Mode)"))),
};
let fmt = doc.pixel_format();
let n = fmt.channels().max(1);
let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None);
let values = surface.read_region(canvas);
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
// Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink
// in CMYK (0 = paper).
let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 };
let mut data = Vec::with_capacity((w * h).saturating_mul(device));
for px in values.chunks(n) {
let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0);
for v in px.iter().take(device) {
data.push(to8(v * a + paper * (1.0 - a)));
}
}
let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) };
let dpi = f64::from(doc.resolution_dpi.max(1.0));
let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi);
let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None };
let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()];
if !doc.metadata.text.is_empty() {
warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into());
}
Ok(ExportResult { bytes: print_pdf(&page), warnings })
}
#[cfg(test)]
mod tests;
+131
View File
@@ -0,0 +1,131 @@
//! PDF export: structure, pixel oracle, colour modes and refusal paths.
use super::*;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_doc::{Layer, LayerContent, Size};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface {
let mut s = Surface::new(fmt);
let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize);
let data: Vec<f32> = px.iter().copied().cycle().take(w * h * px.len()).collect();
s.write_region(Rect::from_xywh(x0, y0, w as u32, h as u32), &data);
s
}
/// The opaque paper colour of `mode`, with alpha.
fn paper(mode: ColorMode) -> Vec<f32> {
match mode {
ColorMode::Grayscale => vec![1.0, 1.0],
ColorMode::Cmyk => vec![0.0, 0.0, 0.0, 0.0, 1.0],
_ => vec![1.0, 1.0, 1.0, 1.0],
}
}
fn doc(mode: ColorMode) -> Document {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let bg = paper(mode);
let mut d = Document::new("t.pdf", Size { width: 4, height: 2 }, mode, SampleType::F32);
d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 4, 2, &bg)))];
d
}
/// The bytes of the stream following `marker` (e.g. "/Subtype /Image"), inflated.
fn stream_after(pdf: &[u8], marker: &str) -> Vec<u8> {
let at = pdf.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("marker");
let body = &pdf[at..];
let len_at = body.windows(8).position(|w| w == b"/Length ").expect("length") + 8;
let len_end = body[len_at..].iter().position(|b| !b.is_ascii_digit()).expect("length end");
let len: usize = std::str::from_utf8(&body[len_at..len_at + len_end]).unwrap().parse().unwrap();
let start = body.windows(7).position(|w| w == b"stream\n").expect("stream") + 7;
let mut out = Vec::new();
std::io::Read::read_to_end(&mut flate2::read::ZlibDecoder::new(&body[start..start + len]), &mut out).unwrap();
out
}
fn has(pdf: &[u8], needle: &str) -> bool {
pdf.windows(needle.len()).any(|w| w == needle.as_bytes())
}
fn check_structure(pdf: &[u8]) {
assert!(pdf.starts_with(b"%PDF-1."), "PDF header");
assert!(pdf.ends_with(b"%%EOF\n"), "EOF marker");
let start = pdf.windows(9).rposition(|w| w == b"startxref").unwrap() + 9;
let off: usize = std::str::from_utf8(&pdf[start..]).unwrap().split_whitespace().next().unwrap().parse().unwrap();
assert_eq!(&pdf[off..off + 4], b"xref", "startxref points at the xref table");
// Every xref offset really precedes its `n 0 obj` header. The xref/trailer region is pure
// ASCII, so it is safe to decode just that slice.
let table = std::str::from_utf8(&pdf[off..]).unwrap().lines().collect::<Vec<_>>();
let count: usize = table[1].split_whitespace().nth(1).unwrap().parse().unwrap();
for (i, line) in table.iter().skip(3).take(count - 1).enumerate() {
let o: usize = line[..10].parse().unwrap();
let want = format!("{} 0 obj", i + 1);
assert_eq!(&pdf[o..o + want.len()], want.as_bytes(), "xref offset for object {}", i + 1);
}
}
#[test]
fn a_flat_rgb_document_saves_as_one_page_pdf() {
let d = doc(ColorMode::Rgb);
let out = export(&d).unwrap();
let pdf = &out.bytes;
check_structure(pdf);
assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in");
assert!(has(pdf, "/Width 4 /Height 2 /ColorSpace /DeviceRGB"), "RGB is DeviceRGB");
assert_eq!(stream_after(pdf, "/Subtype /Image"), vec![255u8; 4 * 2 * 3], "the white paper, 8-bit");
assert!(out.warnings.iter().any(|w| w.contains("flattened")), "layers are not preserved");
// Through the general dispatch as well.
let via = crate::export(&d, "out.pdf", &crate::ExportOptions::default()).unwrap();
assert_eq!(via.bytes, *pdf);
}
#[test]
fn layers_composite_over_white_before_the_page() {
let mut d = doc(ColorMode::Rgb);
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5]))));
let out = export(&d).unwrap();
let img = stream_after(&out.bytes, "/Subtype /Image");
// Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white.
assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]);
assert_eq!(&img[0..3], &[255, 255, 255]);
}
#[test]
fn greyscale_and_cmyk_pages_keep_their_channels() {
let g = export(&doc(ColorMode::Grayscale)).unwrap();
assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray");
assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel");
let c = export(&doc(ColorMode::Cmyk)).unwrap();
assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK");
assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page");
}
#[test]
fn the_page_measures_at_the_document_resolution() {
let mut d = doc(ColorMode::Rgb);
d.size = Size { width: 300, height: 150 };
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))];
d.resolution_dpi = 300.0;
let out = export(&d).unwrap();
assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch");
}
#[test]
fn an_embedded_profile_travels_as_iccbased() {
let mut d = doc(ColorMode::Rgb);
d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8]));
let out = export(&d).unwrap();
assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile");
assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive");
}
#[test]
fn other_modes_are_refused_with_an_actionable_error() {
let d = doc(ColorMode::Lab);
let e = export(&d).unwrap_err().to_string();
assert!(e.contains("RGB") && e.contains("Mode"), "{e}");
assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err());
}
+28 -2
View File
@@ -164,6 +164,14 @@ struct SmartOut {
keep_fx: std::collections::HashSet<String>,
/// Regenerated `FEid` items.
new_fx: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
/// The unfiltered pixels of each smart object whose filter mask shows: its layer record holds
/// the filtered pixels without the mask, as Photoshop's do (`compose::filter_mask`).
unfiltered: std::collections::HashMap<photocraft_doc::LayerId, Surface>,
}
/// Whether a smart object's filter mask shows: enabled over at least one visible, enabled filter.
fn masked(sm: &photocraft_doc::SmartObject) -> bool {
sm.filter_mask.as_ref().is_some_and(|m| m.enabled) && sm.filters_enabled && sm.smart_filters.iter().any(|f| f.visible)
}
/// `FEid` data padded to 4 bytes inside the block length: Photoshop counts the padding in the
@@ -760,6 +768,12 @@ impl Ex {
}
if !stack.filters.is_empty() {
self.smart.keep_fx.insert(t.placed.clone());
if masked(sm) {
let fmt = self.fmt;
if let Some(u) = self.smart.old_fx.iter().find(|i| i.id == t.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, fmt)) {
self.smart.unfiltered.insert(l.id, u);
}
}
}
return;
}
@@ -795,7 +809,14 @@ impl Ex {
None => photocraft_algo::warp::place_source(&img, img_bounds, &sm.transform, sm.warp.as_ref()),
};
match unfiltered {
Ok(unfiltered) => crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt),
Ok(unfiltered) => {
let item = crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt);
if item.is_some() && masked(sm) {
let u = if unfiltered.format() == self.fmt { unfiltered } else { unfiltered.convert(self.fmt) };
self.smart.unfiltered.insert(l.id, u);
}
item
}
Err(e) => {
warnings.push(format!("smart filter mask was not re-rendered for PSD export ({e})"));
None
@@ -1072,7 +1093,12 @@ impl Ex {
self.records.push(r);
}
LayerContent::Smart(sm) => {
let r = self.record(l, extra, sm.cache.as_ref());
// Photoshop's record holds the filtered pixels without the filter mask.
let unmixed = match (self.smart.unfiltered.get(&l.id), sm.cache.as_ref(), sm.filter_mask.as_ref()) {
(Some(u), Some(c), Some(m)) if masked(sm) => Some(photocraft_compose::filter_mask::unmix(u, c, m)),
_ => None,
};
let r = self.record(l, extra, unmixed.as_ref().or(sm.cache.as_ref()));
self.records.push(r);
}
}
+37 -4
View File
@@ -403,12 +403,26 @@ impl Ctx<'_> {
Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, &name),
_ => None,
};
// Photoshop stores the filtered pixels without the filter mask and mixes the unfiltered
// ones (`FEid`) back in through it; our smart objects hold the mixed result.
let stored = self.record_surface(rec, &name);
let shown = stack.enabled && stack.filters.iter().any(|f| f.visible);
let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) {
(Some(p), Some(m)) => {
let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
match unfiltered {
Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m),
None => stored,
}
}
_ => stored,
};
LayerContent::Smart(SmartObject {
contents_id,
source: SmartSource::Linked { path: id },
transform,
smart_filters: stack.filters,
cache: Some(self.record_surface(rec, &name)),
cache: Some(cache),
psd_raw: principal(k),
filters_enabled: stack.enabled,
filter_mask,
@@ -737,6 +751,26 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
/// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports
/// progress. `None` when cancelled.
pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec<String>)> {
psd_to_document_decoded(file, ctl, None)
}
/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is*
/// the image), a file without layers, or extra alpha/spot channels behind the colour ones.
pub(crate) fn merged_needed(file: &PsdFile) -> bool {
let h = &file.header;
let mode = doc_mode(h.color_mode).unwrap_or(ColorMode::Rgb);
// A layered mode is its own pixel format, so these are the document's colour channels.
let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1;
!layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha())
}
/// [`psd_to_document_with`] with the merged image already decoded (by
/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again.
pub(crate) fn psd_to_document_decoded(
file: &PsdFile,
ctl: &photocraft_raster::Interrupt,
decoded: Option<photocraft_psd::Result<Vec<u8>>>,
) -> Option<(Document, Vec<String>)> {
let h = &file.header;
let mut warnings = Vec::new();
let mode = doc_mode(h.color_mode).unwrap_or_else(|| {
@@ -859,9 +893,8 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
// multichannel file (it *is* the image), or extra alpha/spot channels behind the colour
// ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly
// half the file's bytes, decoded and thrown away on every open before this.
let extra = usize::from(h.channels) > cc + usize::from(file.merged_has_alpha());
let merged = if !layered || file.layers().is_empty() || extra {
let m = file.decode_merged();
let merged = if merged_needed(file) {
let m = decoded.unwrap_or_else(|| file.decode_merged());
if ctl.cancelled() {
return None;
}
+39
View File
@@ -886,6 +886,45 @@ pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &Filt
Ok(Some(LayerMask { surface, enabled: stack.mask_enabled, linked: stack.mask_linked, density: 1.0, feather: 0.0 }))
}
/// The unfiltered placed pixels of an `FEid` item (the inverse of [`feid_item`]), in `fmt`: colour
/// channels from slot 0, transparency from the last slot (opaque without it). `None` when the
/// item has no colour planes, its depth isn't `fmt`'s, or a plane doesn't decode.
pub fn unfiltered_from_item(item: &FilterEffectsItem, fmt: PixelFormat) -> Option<Surface> {
let depth = u16::try_from(item.depth).ok()?;
if u32::from(crate::pixels::psd_depth(fmt.sample)) != item.depth || !fmt.alpha {
return None;
}
let (w, h) = item.rect.size().ok()?;
let n = w.checked_mul(h)?;
let cc = fmt.mode.color_channels();
let mut planes = Vec::with_capacity(cc + 1);
for slot in (0..cc).chain(std::iter::once(item.slots.len().saturating_sub(1))) {
planes.push(match item.slots.get(slot).and_then(Option::as_ref) {
Some(p) => Some(p.decode(w, h, depth).ok()?),
None if slot < cc => return None,
None => None,
});
}
let refs: Vec<Option<&[u8]>> = planes.iter().map(|p| p.as_deref()).collect();
let mut fill = vec![crate::pixels::zero_sample(fmt.sample); cc];
// Without a transparency plane the pixels are opaque (a native-endian full sample).
fill.push(match fmt.sample {
SampleType::U8 => vec![u8::MAX],
SampleType::U16 => u16::MAX.to_ne_bytes().to_vec(),
SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(),
});
let mut invert = vec![fmt.mode == ColorMode::Cmyk; cc];
invert.push(false);
let mut bytes = crate::pixels::interleave(&refs, &fill, n, fmt.sample, &invert);
if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, cc + 1, true);
}
let mut surface = Surface::new(fmt);
surface.write_interleaved(GeomRect::new(item.rect.left, item.rect.top, item.rect.right, item.rect.bottom), &bytes);
surface.prune();
Some(surface)
}
/// An `FEid` item for a smart object with filters: the unfiltered placed pixels Photoshop
/// filters from (colour channels from slot 0, transparency in the last slot; in the document's
/// pixel format, CMYK inverted as in layer channels) and the filter mask over `bounds` (white
+1
View File
@@ -93,6 +93,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
ChannelLayout::Gray | ChannelLayout::GrayA => (PsdMode::Grayscale, 1),
ChannelLayout::Rgb | ChannelLayout::Rgba => (PsdMode::Rgb, 3),
ChannelLayout::Cmyk | ChannelLayout::CmykA => (PsdMode::Cmyk, 4),
ChannelLayout::Lab | ChannelLayout::LabA => (PsdMode::Lab, 3),
};
let depth = match img.sample_type() {
CSample::U8 => 8,
+1 -1
View File
@@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 185, roundtrip_floor: 258 };
const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 188, roundtrip_floor: 258 };
/// The corpus directory; a missing corpus fails the test.
fn locate(src: &Source) -> PathBuf {
+20 -2
View File
@@ -982,14 +982,32 @@ fn tga_default_bits_follow_the_alpha_channels() {
assert_eq!((TgaBits::from_bits(24), TgaBits::from_bits(32), TgaBits::from_bits(16)), (Some(TgaBits::Bits24), Some(TgaBits::Bits32), None));
}
#[test]
fn a_lab_document_is_written_as_a_lab_tiff_and_opens_as_lab() {
// #4936: TIFF stores CIE L*a*b* (CIELab): a Lab document with one layer keeps its pixels.
for depth in [SampleType::U8, SampleType::U16] {
let d = single(ColorMode::Lab, depth, false);
let r = export(&d, "tif", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().all(|w| !w.contains("sRGB") && !w.contains("flattened")), "{depth:?}: {:?}", r.warnings);
let back = import("x.tif", &r.bytes).unwrap().document;
assert_eq!((back.mode, back.depth), (ColorMode::Lab, depth));
pixels_eq(&d, &back, if depth == SampleType::U8 { 0.0 } else { 1.5 / 65535.0 });
}
// Other formats still go through sRGB.
let r = export(&single(ColorMode::Lab, SampleType::U8, false), "png", &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("sRGB")), "{:?}", r.warnings);
}
#[test]
fn a_lab_document_with_one_layer_is_not_reported_as_flattened() {
// Lab always goes through the sRGB composite, but a lone layer loses nothing to it.
// A lone layer loses nothing: TIFF keeps it as Lab, the others go through the sRGB composite.
let d = single(ColorMode::Lab, SampleType::U8, false);
for ext in ["tif", "png", "jpg"] {
let r = export(&d, ext, &ExportOptions::default()).unwrap();
assert!(r.warnings.iter().all(|w| !w.contains("flattened")), "{ext}: {:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("Lab composite written as sRGB")), "{ext}: {:?}", r.warnings);
if ext != "tif" {
assert!(r.warnings.iter().any(|w| w.contains("Lab composite written as sRGB")), "{ext}: {:?}", r.warnings);
}
}
// Two layers are flattened, and say so.
let mut two = single(ColorMode::Lab, SampleType::U8, false);
+81 -1
View File
@@ -209,14 +209,55 @@ impl History {
self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
}
/// Bytes of the tiles only `extra` documents hold (History panel snapshots, #4530): not
/// `current`'s and not any state's. 0 without `extra`, without walking the states.
pub fn extra_bytes(&self, current: &Document, extra: &[&Document]) -> usize {
if extra.is_empty() {
return 0;
}
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
for s in self.undo.iter().chain(self.redo.iter()) {
tile_bytes(&s.doc, &mut seen);
}
extra.iter().fold(0, |n, d| n.saturating_add(tile_bytes(d, &mut seen)))
}
/// [`History::unique_bytes`] less the tiles `keep` documents also hold: what clearing the
/// history frees while they stay (Edit › Purge › Histories keeps the snapshots).
pub fn unique_bytes_besides(&self, current: &Document, keep: &[&Document]) -> usize {
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
for d in keep {
tile_bytes(d, &mut seen);
}
self.undo.iter().chain(self.redo.iter()).fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
}
/// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the
/// tiles only history holds (newest states first). The oldest undo states that don't fit are
/// dropped; the most recent one is always kept so the last step can be undone. Returns how
/// many states were dropped.
pub fn trim(&mut self, current: &Document) -> usize {
self.trim_with(current, &[])
}
/// [`History::trim`] counting the tiles only `pinned` documents hold (History panel
/// snapshots, which outlive the states) against the budget too, so they can't take memory
/// beyond it unseen (#4530). The most recent state is still always kept.
pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize {
if self.max_bytes == 0 || self.undo.len() <= 1 {
return 0;
}
// An upper bound of what only `pinned` holds (some of it may be in states too): if the
// states fit beside it, they fit.
let pinned_bytes = if pinned.is_empty() {
0
} else {
let mut seen = HashSet::new();
tile_bytes(current, &mut seen);
pinned.iter().fold(0usize, |n, d| n.saturating_add(tile_bytes(d, &mut seen)))
};
let tiles = self.tiles.get_or_insert_with(|| {
let mut tiles = tiles::Tiles::default();
for state in self.undo.iter().chain(&self.redo) {
@@ -224,11 +265,14 @@ impl History {
}
tiles
});
if tiles.fits(current, self.max_bytes) {
if tiles.fits(current, self.max_bytes.saturating_sub(pinned_bytes)) {
return 0;
}
let mut seen = HashSet::new();
let mut total = tile_bytes(current, &mut seen);
for d in pinned {
total = total.saturating_add(tile_bytes(d, &mut seen));
}
for s in self.redo.iter().rev() {
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
}
@@ -392,6 +436,42 @@ mod tests {
assert!(h.undo(cur.clone()).is_some());
}
/// Snapshot-only tiles count against the budget (#4530); tiles a snapshot shares with the
/// document or a state count once, and a purge frees only what no snapshot keeps.
#[test]
fn snapshot_tiles_count_against_the_budget() {
let mut h = History::new(50);
let mut cur = Arc::new(base());
let tile = 256 * 256 * 4;
let paint = |d: &mut Document, v: f32| {
let id = d.layers[0].id;
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[v, 0.0, 0.0, 1.0]);
};
// A snapshot holding a tile nothing else does, then one sharing the first state's tile.
let mut own = (*cur).clone();
paint(&mut own, 0.9);
edit(&mut h, &mut cur, "paint 0", |d| paint(d, 0.1));
let shared = h.state(0).unwrap();
for i in 1..6 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| paint(d, i as f32 / 8.0));
}
let snaps = [&own, &*shared];
assert_eq!(h.extra_bytes(&cur, &snaps), tile, "the shared tile is history's");
assert_eq!(h.extra_bytes(&cur, &[]), 0);
assert_eq!(h.unique_bytes(&cur), 6 * tile);
assert_eq!(h.unique_bytes_besides(&cur, &snaps), 5 * tile, "clearing keeps the shared tile");
// Both snapshot tiles stay whatever is trimmed (the shared one too), so beside the
// current tile they leave room for two history tiles; without them, four would fit.
h.max_bytes = 5 * tile;
assert_eq!(h.trim_with(&cur, &snaps), 4);
assert_eq!(h.past_len(), 2);
assert_eq!(h.pixel_bytes(&cur) + h.extra_bytes(&cur, &snaps), 5 * tile);
// Still keeps the last step undoable.
h.max_bytes = 1;
h.trim_with(&cur, &snaps);
assert_eq!(h.past_len(), 1);
}
#[test]
fn history_shares_untouched_tiles() {
let mut h = History::default();
+8 -4
View File
@@ -20,6 +20,8 @@ A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB
```rust
// Top level
PsdFile::from_bytes(&[u8]) -> Result<PsdFile>
PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option<Result<Vec<u8>>>)>
// + decode_merged() when want(&file): ZIP inflated once
PsdFile::to_bytes(&self) -> Result<Vec<u8>>
PsdFile::open(path) / save(path) // native only
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement,
@@ -112,10 +114,12 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla
| Actions (`.atn`) | ✓ | ✓ | ✓ | `atn::parse` / `atn::write`: version-16 sets (Photoshop 6+): actions with function key, modifiers and colour; steps with event code or string name, panel name, enabled/dialog flags and their descriptor. Exact round trip; version-12 (Photoshop 5) files are rejected as unsupported |
Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel
metadata and the total padded size are checked against `MAX_DECODED_BYTES` before any
plane is decoded. Fully populated channels reuse their decoded buffer without a second
copy; allocations for padded channels return an error on failure. There is no additional
padding-specific size limit.
metadata is checked before any plane is decoded, against one `PatternBudget` shared by every
pattern of a parse (a block, a `.pat` file): `MAX_DECODED_BYTES` decoded and 512 MiB of zero
padding in total. `parse_pattern_block_with` lets several blocks of one document share a budget;
`.abr` patterns are charged to the file's sample budget. All channels of a pattern must have the
same depth. Fully populated channels reuse their decoded buffer without a second copy;
allocations for padded channels return an error on failure.
### Preserved raw, not yet typed (parsers planned for M8)
+54 -10
View File
@@ -37,7 +37,7 @@ use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}
use crate::descriptor::{Descriptor, Value, VersionedDescriptor};
use crate::error::{PsdError, Result};
use crate::header::Version;
use crate::patterns::{PsdPattern, parse_pattern_block, write_pattern_block};
use crate::patterns::{PatternBudget, PsdPattern, parse_pattern_block_with, write_pattern_block};
/// Largest sampled tip edge accepted (Photoshop's own limit is 5000 px).
pub const MAX_EDGE: u32 = 8192;
@@ -306,11 +306,12 @@ fn read_samp(data: &[u8], subversion: u16, out: &mut AbrFile, budget: &mut Budge
fn read_desc(data: &[u8], out: &mut AbrFile) {
match VersionedDescriptor::parse_prefix(data) {
Ok((vd, _)) => match vd.descriptor.get("Brsh") {
// Moves the presets out of the parsed descriptor instead of cloning each one.
Ok((vd, _)) => match vd.descriptor.items.into_iter().find(|(k, _)| k.is("Brsh")).map(|(_, v)| v) {
Some(Value::List(items)) => {
for v in items.iter().take(MAX_BRUSHES) {
for v in items.into_iter().take(MAX_BRUSHES) {
if let Value::Descriptor(d) = v {
out.presets.push(d.clone());
out.presets.push(d);
}
}
}
@@ -421,11 +422,15 @@ fn apply_hierarchy(out: &mut AbrFile, phry: Option<&[u8]>) {
}
fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> {
parse_v6_within(r, out, &mut Budget(MAX_TOTAL_BYTES))
}
/// [`parse_v6`] with the samples and patterns sharing `budget`.
fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result<()> {
out.subversion = r.u16()?;
if !matches!(out.subversion, 1 | 2) {
return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion)));
}
let mut budget = Budget(MAX_TOTAL_BYTES);
let mut hierarchy = None;
while r.left() >= 12 {
let sig = r.arr::<4>()?;
@@ -448,11 +453,21 @@ fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> {
r.p += pad;
}
match &key {
b"samp" => read_samp(data, out.subversion, out, &mut budget)?,
b"patt" => match parse_pattern_block(data) {
Ok(p) => out.patterns.extend(p),
Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")),
},
b"samp" => read_samp(data, out.subversion, out, budget)?,
b"patt" => {
// Patterns share the samples' budget, so neither can claim it twice.
let limit = u64::try_from(budget.0).unwrap_or(u64::MAX);
let mut patterns = PatternBudget::with_decoded_limit(limit);
let left = patterns.decoded_left();
match parse_pattern_block_with(data, &mut patterns) {
Ok(p) => {
let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX);
budget.spend(spent)?;
out.patterns.extend(p);
}
Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")),
}
}
b"desc" => read_desc(data, out),
// Read once `desc` gives the preset count; `phry` may come first.
b"phry" => hierarchy = Some(data),
@@ -663,6 +678,35 @@ mod tests {
AbrSample { id: id.into(), width: w, height: h, depth, data }
}
#[test]
fn embedded_patterns_spend_the_samples_budget() {
let p = PsdPattern {
mode: 3,
width: 5,
height: 3,
name: "p".into(),
id: "id".into(),
palette: None,
depth: 8,
channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]],
alpha: None,
};
let abr = write_v6(2, &[sample("s", 10, 10, 8)], &[p.clone(), p], &[], true).unwrap();
let parse_within = |left: usize| {
let mut out = AbrFile::default();
let mut budget = Budget(left);
parse_v6_within(&mut Rd::new(&abr[2..]), &mut out, &mut budget).unwrap();
(out, budget.0)
};
// 100 sample bytes plus two 45-byte patterns.
let (out, left) = parse_within(190);
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0));
// One byte short: the patterns no longer fit after the sample (they used not to count).
let (out, left) = parse_within(189);
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 89));
assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings);
}
#[test]
fn v1_v2_round_trip_raw_and_rle() {
for version in [1u16, 2] {
+5 -2
View File
@@ -84,7 +84,8 @@ impl PixelData {
}
fn composite_plane(&self, c: usize, n: usize) -> Vec<u8> {
// Photoshop stores transparent RGB composites against white; layer channels stay straight.
// Photoshop stores transparent RGB composites against white; layer channels stay straight,
// and so do Gray and CMYK composites here (see `PsdBuilder::composite`).
match self {
Self::Rgba8(v) if c < 3 => v
.as_chunks::<4>()
@@ -290,7 +291,9 @@ impl PsdBuilder {
Ok(self)
}
/// Supplies the merged composite (straight samples, same format as layers, full canvas).
/// Transparent RGB is stored against white, quantized at the document depth.
/// Transparent RGB is stored against white, quantized at the document depth. Gray and CMYK
/// composites with alpha are stored straight (not matted against white as Photoshop would):
/// a test that reads them as a Photoshop merged image has to allow for that.
pub fn composite(&mut self, pixels: PixelData) -> &mut Self {
self.composite = Some(pixels);
self
+8
View File
@@ -279,10 +279,18 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl
}
}
#[cfg(test)]
thread_local! {
/// ZIP streams inflated on this thread (tests count passes over the merged image).
pub(crate) static INFLATES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// Keep only the requested pixels, but consume the trailer to verify Adler-32.
// A bounded surplus tolerates padding without unbounded decompression work.
fn inflate_checked(data: &[u8], expected: u64, keep: bool) -> Result<Vec<u8>> {
const OOM: PsdError = PsdError::LimitExceeded("not enough memory for the decoded channel data");
#[cfg(test)]
INFLATES.with(|n| n.set(n.get() + 1));
let mut decoder = flate2::Decompress::new(true);
let mut out = Vec::new();
if keep {
+2 -2
View File
@@ -364,7 +364,7 @@ impl VersionedDescriptor {
/// count must not drive the up-front reservation (or abort on allocation).
const MAX_PREALLOC: usize = 4096;
fn vec_for_count<T>(n: u32) -> Vec<T> {
pub(crate) fn vec_for_count<T>(n: u32) -> Vec<T> {
Vec::with_capacity((n as usize).min(MAX_PREALLOC))
}
@@ -422,7 +422,7 @@ fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result<Va
let unit = r.array()?;
let n = r.u32()?;
r.check_count(u64::from(n), 8)?;
let mut values = Vec::with_capacity(n as usize);
let mut values = vec_for_count(n);
for _ in 0..n {
values.push(r.f64()?);
}
+79 -2
View File
@@ -1,5 +1,6 @@
//! The top-level [`PsdFile`] model, parser and writer.
use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, validate_planes};
use crate::error::{PsdError, Result};
use crate::header::{ColorMode, Header, Version};
use crate::image_data::ImageData;
@@ -90,6 +91,17 @@ pub struct PsdFile {
impl PsdFile {
/// Parses a PSD or PSB file.
pub fn from_bytes(data: &[u8]) -> Result<Self> {
Self::from_bytes_and_merged(data, |_| false).map(|(file, _)| file)
}
/// Parses like [`Self::from_bytes`] and, when `want` asks for it, decodes the merged image
/// (as [`Self::decode_merged`] would) in the same pass. `want` sees the file with everything
/// but the merged image parsed (its `image_data` is still empty). A ZIP merged image is then
/// inflated once for both the parse's check of the stream and the decode, not twice.
///
/// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but
/// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it.
pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option<Result<Vec<u8>>>)> {
let mut r = Reader::new(data);
let header = Header::read(&mut r)?;
let psb = header.version.is_psb();
@@ -146,8 +158,39 @@ impl PsdFile {
layer_mask_trailing = s.peek_rest().to_vec();
}
}
let image_data = ImageData::read(&mut r, &header)?;
Ok(PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data })
let image_data = ImageData { compression: Compression::Raw, data: Vec::new() };
let mut file =
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data };
let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None };
if merged.is_none() {
file.image_data = ImageData::read(&mut r, &file.header)?;
}
Ok((file, merged))
}
/// Reads the merged image into `self.image_data` and decodes it. A ZIP stream within the
/// decode limit is inflated once: by the decode, whose stream check is the one
/// [`ImageData::read`] makes. Only when the decode fails is that check run on its own, to
/// tell a file the parse rejects (the outer `Err`) from one it accepts (the inner `Err`).
fn read_merged_decoded(&mut self, r: &mut Reader<'_>) -> Result<Result<Vec<u8>>> {
let layout = ImageData::layout(&self.header);
let compression = Compression::from_u16(r.clone().u16()?);
let zip = matches!(compression, Compression::Zip | Compression::ZipPrediction);
if !zip || layout.total_bytes()? > MAX_DECODED_BYTES {
// Nothing is inflated twice here: the parse's check reads sizes only (or skips an
// over-limit stream, which the decode then refuses).
self.image_data = ImageData::read(r, &self.header)?;
return Ok(self.decode_merged());
}
r.u16()?;
let data = r.peek_rest();
let decoded = decode_planes(compression, data, &layout);
if decoded.is_err() {
validate_planes(compression, data, &layout)?;
}
self.image_data = ImageData { compression, data: data.to_vec() };
r.skip(data.len())?;
Ok(decoded)
}
/// Serializes the file.
@@ -374,6 +417,40 @@ mod write_tests {
let file = PsdFile::from_bytes(&bytes).unwrap();
assert!(!file.layers().is_empty());
assert!(matches!(file.decode_merged(), Err(PsdError::LimitExceeded(_))));
let (same, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| true).unwrap();
assert_eq!(same, file);
assert!(matches!(merged, Some(Err(PsdError::LimitExceeded(_)))));
}
/// Parsing and decoding a ZIP merged image together inflates it once (the parse's stream
/// check is the decode's), with the same model, pixels and errors as the two steps apart.
#[test]
fn merged_zip_image_is_inflated_once_when_decoded_with_the_parse() {
use crate::compression::INFLATES;
let inflates = |f: &dyn Fn()| {
let before = INFLATES.with(std::cell::Cell::get);
f();
INFLATES.with(std::cell::Cell::get) - before
};
for compression in [Compression::Zip, Compression::ZipPrediction] {
let bytes = testgen::small(Version::Psd, compression).to_bytes().unwrap();
let file = PsdFile::from_bytes(&bytes).unwrap();
let pixels = file.decode_merged().unwrap();
let apart = inflates(&|| drop(PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap()));
let together = inflates(&|| {
let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |f| f.image_data.data.is_empty()).unwrap();
assert_eq!(parsed, file);
assert_eq!(merged.unwrap().unwrap(), pixels);
});
assert_eq!(together + 1, apart, "{compression:?}");
let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| false).unwrap();
assert_eq!((parsed, merged), (file, None));
// A broken stream (its checksum) is still the parse's error.
let mut broken = bytes.clone();
*broken.last_mut().unwrap() ^= 0xff;
let alone = PsdFile::from_bytes(&broken).unwrap_err().to_string();
assert_eq!(PsdFile::from_bytes_and_merged(&broken, |_| true).unwrap_err().to_string(), alone);
}
}
#[test]
-35
View File
@@ -39,19 +39,6 @@ impl ImageData {
decode_planes(self.compression, &self.data, &Self::layout(header))
}
/// Decodes and returns a single channel plane.
pub fn decode_channel(&self, header: &Header, index: usize) -> Result<Vec<u8>> {
let invalid = || crate::error::PsdError::invalid(format!("no merged channel {index}"));
if index >= usize::from(header.channels) {
return Err(invalid());
}
let plane = header.row_bytes().checked_mul(header.height as usize).ok_or_else(invalid)?;
let start = index.checked_mul(plane).ok_or_else(invalid)?;
let end = start.checked_add(plane).ok_or_else(invalid)?;
let all = self.decode(header)?;
all.get(start..end).map(<[u8]>::to_vec).ok_or_else(invalid)
}
pub(crate) fn read(r: &mut Reader<'_>, header: &Header) -> Result<Self> {
let compression = Compression::from_u16(r.u16()?);
let data = r.peek_rest();
@@ -61,25 +48,3 @@ impl ImageData {
Ok(ImageData { compression, data })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::header::{ColorMode, Version};
#[test]
fn merged_channels_reject_invalid_indices_without_decoding_or_overflow() {
let header = Header::new(Version::Psd, 2, 1, 3, 8, ColorMode::Rgb);
let data = ImageData { compression: Compression::Raw, data: vec![10, 11, 20, 21, 30, 31] };
for (index, expected) in [(0, [10, 11]), (1, [20, 21]), (2, [30, 31])] {
assert_eq!(data.decode_channel(&header, index).unwrap(), expected.to_vec());
}
for index in [3, 4, usize::MAX] {
assert!(data.decode_channel(&header, index).is_err(), "invalid channel {index}");
}
// Reject an invalid index before trying to decode corrupt merged data.
let corrupt = ImageData { compression: Compression::Raw, data: vec![] };
let err = corrupt.decode_channel(&header, usize::MAX).unwrap_err().to_string();
assert!(err.contains("no merged channel"), "{err}");
}
}
+81 -5
View File
@@ -5,7 +5,7 @@ use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}
use crate::error::{PsdError, Result};
use crate::header::{MAX_DIMENSION, Version};
use crate::io::{Reader, WriteExt, decode_legacy_name, read_pascal, write_pascal};
use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, write_blocks};
use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, uses_long_length, write_blocks};
/// Channel id of the transparency mask.
pub const CHANNEL_TRANSPARENCY: i16 = -1;
@@ -272,6 +272,18 @@ impl LayerMask {
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first })
}
/// Bytes [`Self::write`] produces.
fn encoded_len(&self) -> u64 {
let real = if self.real.is_some() { 18 } else { 0 };
let parameters = self.parameters.as_ref().map_or(0, |p| {
1 + u64::from(p.user_density.is_some())
+ 8 * u64::from(p.user_feather.is_some())
+ u64::from(p.vector_density.is_some())
+ 8 * u64::from(p.vector_feather.is_some())
});
18 + real + parameters + self.trailing.len() as u64
}
fn write(&self, out: &mut Vec<u8>) {
self.rect.write(out);
out.put_u8(self.default_color);
@@ -586,6 +598,36 @@ impl LayerRecord {
Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens))
}
/// Bytes [`Self::write`] produces, computed without serializing. Errors where `write` would.
fn encoded_len(&self, version: Version) -> Result<u64> {
let too_big = || PsdError::LimitExceeded("section too large for 32-bit length (use PSB)");
u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?;
let channel_len: u64 = if version.is_psb() { 8 } else { 4 };
if !version.is_psb() && self.channels.iter().any(|c| u32::try_from(c.stored_len()).is_err()) {
return Err(too_big());
}
let mask = match &self.mask {
MaskData::None => 0,
MaskData::Mask(mask) => mask.encoded_len(),
MaskData::Raw(b) => b.len() as u64,
};
u32::try_from(mask).map_err(|_| too_big())?;
let ranges = self.blending_ranges.data.len() as u64;
u32::try_from(ranges).map_err(|_| too_big())?;
let name = 1 + self.name.len().min(255) as u64;
let name = name.next_multiple_of(4);
let mut extra = (4 + mask).saturating_add(4 + ranges).saturating_add(name).saturating_add(self.extra_trailing.len() as u64);
for b in &self.blocks {
if !uses_long_length(version, &b.key) && u32::try_from(b.data.len()).is_err() {
return Err(too_big());
}
extra = extra.saturating_add(b.encoded_len(version) as u64);
}
u32::try_from(extra).map_err(|_| too_big())?;
// rect, channel count, channel entries, signature + blend key, four flag bytes, extra length.
Ok((16 + 2 + self.channels.len() as u64 * (2 + channel_len) + 8 + 4 + 4).saturating_add(extra))
}
fn write(&self, out: &mut Vec<u8>, version: Version) -> Result<()> {
self.rect.write(out);
let nch = u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?;
@@ -667,12 +709,12 @@ impl LayerInfo {
/// Length of the body (layer count, records and channel data) without
/// its trailing padding.
pub fn unpadded_len(&self, version: Version) -> Result<u64> {
let mut records = Vec::new();
records.put_i16(0);
// Computed from the field sizes: serializing the records just to measure them would copy
// every tagged block.
let mut n = 2u64;
for l in &self.layers {
l.write(&mut records, version)?;
n = n.saturating_add(l.encoded_len(version)?);
}
let mut n = records.len() as u64;
for c in self.layers.iter().flat_map(|l| &l.channels).filter(|c| c.compression.is_some()) {
n = n.saturating_add(2).saturating_add(c.data.len() as u64);
}
@@ -1039,6 +1081,40 @@ mod tests {
assert_eq!(info.layers[0].decode_channel(0, 8, Version::Psd).unwrap(), Vec::<u8>::new());
}
/// The computed length matches the bytes written for every mask form, long names, custom block
/// padding, long-length blocks and trailing bytes.
#[test]
fn unpadded_len_matches_the_written_body() {
for version in [Version::Psd, Version::Psb] {
let mut layers = vec![sample_record(version), LayerRecord::default()];
for (params, real, real_first) in [(true, true, true), (true, true, false), (true, false, true), (false, true, true)] {
let mut mask = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, LayerMask::FLAG_PARAMETERS);
mask.parameters = params.then_some(MaskParameters {
flags: 0x0f,
user_density: Some(1),
user_feather: Some(2.5),
vector_density: Some(3),
vector_feather: None,
});
mask.real = real.then_some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 1, 1) });
mask.real_first = real_first;
mask.trailing = vec![0; 3];
let mut rec = sample_record(version);
rec.mask = MaskData::Mask(mask);
rec.name = vec![b'n'; 300];
rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"lsct", data: vec![1; 5], padding: Some(vec![0; 3]) });
rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"Lr16", data: vec![2; 7], padding: None });
rec.extra_trailing = vec![9; 2];
layers.push(rec);
}
layers.push(LayerRecord { mask: MaskData::Raw(vec![5; 11]), name: b"ab".to_vec(), ..Default::default() });
let info = LayerInfo { layers, ..Default::default() };
let mut out = Vec::new();
info.write_unpadded(&mut out, version).unwrap();
assert_eq!(info.unpadded_len(version).unwrap(), out.len() as u64, "{version:?}");
}
}
/// Padding without cloning the layers writes the same bytes as `pad_to` on a clone, and the
/// channel data length hint is exact.
#[test]
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn parse_shmd(data: &[u8]) -> Result<Vec<MetadataItem>> {
let mut r = Reader::new(data);
let count = r.u32()?;
r.check_count(u64::from(count), 16)?;
let mut out = Vec::with_capacity(count as usize);
let mut out = crate::descriptor::vec_for_count(count);
for _ in 0..count {
let signature = r.array()?;
let key = r.array()?;
+103 -19
View File
@@ -26,14 +26,42 @@ use crate::header::Version;
/// Largest pattern edge accepted (Photoshop's own limit is far lower).
const MAX_EDGE: u32 = 30_000;
/// Budget for zero-padded channel planes summed over one pattern. Photoshop writes every channel
/// at the pattern rectangle, so a smaller channel rectangle is only padding; without this cap a
/// few hundred bytes (1x1 channels, a huge pattern rectangle) would allocate up to
/// `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`.
/// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat`
/// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel
/// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern
/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`.
const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20;
// A PackBits run packet emits at most 128 bytes from a 2-byte packet.
const MAX_RLE_EXPANSION: u64 = 64;
/// Decoded bytes left for the patterns of one parse. Shared by every pattern read in one call, so
/// many patterns can't each claim the whole per-pattern allowance.
#[derive(Debug, Clone)]
pub struct PatternBudget {
decoded_left: u64,
padded_left: u64,
}
impl Default for PatternBudget {
fn default() -> Self {
PatternBudget { decoded_left: MAX_DECODED_BYTES, padded_left: MAX_PADDED_PATTERN_BYTES }
}
}
impl PatternBudget {
/// A budget of at most `decoded` decoded bytes (zero padding included), and never more than
/// the default limits.
pub fn with_decoded_limit(decoded: u64) -> Self {
let d = Self::default();
PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) }
}
/// Decoded bytes still available.
pub fn decoded_left(&self) -> u64 {
self.decoded_left
}
}
/// One pattern tile with planar, big-endian samples.
#[derive(Debug, Clone, PartialEq)]
pub struct PsdPattern {
@@ -109,7 +137,7 @@ impl<'a> Rd<'a> {
}
}
fn read_pattern(r: &mut Rd) -> Result<PsdPattern> {
fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result<PsdPattern> {
let version = r.u32()?;
if version != 1 {
return Err(PsdError::invalid(format!("pattern version {version}")));
@@ -156,10 +184,10 @@ fn read_pattern(r: &mut Rd) -> Result<PsdPattern> {
// Validate every channel and the total padded size before decoding any plane.
// Otherwise a later channel can exceed the existing pattern budget only after
// earlier channels have already allocated several gigabytes of zero padding.
// `budget` is shared by every pattern of the parse, so many patterns can't each pad up to the
// per-parse cap.
let mut channels = Vec::new();
let mut depth = 8u16;
let mut decoded_bytes = 0u64;
let mut padded_bytes = 0u64;
let mut first_depth: Option<u16> = None;
for _ in 0..count + 2 {
if v.p >= body.len() {
break;
@@ -183,7 +211,10 @@ fn read_pattern(r: &mut Rd) -> Result<PsdPattern> {
if !matches!(d, 1 | 8 | 16 | 32) {
return Err(PsdError::invalid(format!("pattern depth {d}")));
}
depth = d;
// Every plane is sized by one depth; a channel at another depth can't be written back.
if *first_depth.get_or_insert(d) != d {
return Err(PsdError::invalid("pattern channels have different depths"));
}
if ct < top || cl < left || cb > bottom || cr > right || cb < ct || cr < cl {
return Err(PsdError::invalid("pattern channel rectangle falls outside pattern rectangle"));
}
@@ -195,15 +226,10 @@ fn read_pattern(r: &mut Rd) -> Result<PsdPattern> {
let channel_bytes = channel_layout.total_bytes()?;
let layout = PlaneLayout { planes: 1, width: w as usize, height: h as usize, depth: d, version: Version::Psd };
let plane_bytes = layout.total_bytes()?;
decoded_bytes = decoded_bytes.checked_add(plane_bytes).ok_or(PsdError::LimitExceeded("pattern decoded size overflow"))?;
if decoded_bytes > MAX_DECODED_BYTES {
return Err(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES"));
}
budget.decoded_left = budget.decoded_left.checked_sub(plane_bytes).ok_or(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES"))?;
if cw != w as usize || ch != h as usize {
padded_bytes = padded_bytes.checked_add(plane_bytes).ok_or(PsdError::LimitExceeded("pattern padded size overflow"))?;
if padded_bytes > MAX_PADDED_PATTERN_BYTES {
return Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES"));
}
budget.padded_left =
budget.padded_left.checked_sub(plane_bytes).ok_or(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES"))?;
}
let _ = layout.decoded_len()?;
let compression = if comp == 1 {
@@ -244,6 +270,7 @@ fn read_pattern(r: &mut Rd) -> Result<PsdPattern> {
}
let alpha = (planes.len() > nc).then(|| planes.remove(nc));
planes.truncate(nc);
let depth = first_depth.unwrap_or(8);
Ok(PsdPattern { mode, width: w, height: h, name, id, palette, depth, channels: planes, alpha })
}
@@ -394,6 +421,13 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec<u8>) -> Result<()> {
/// Parses the data of a `Patt` / `Pat2` / `Pat3` global block.
pub fn parse_pattern_block(data: &[u8]) -> Result<Vec<PsdPattern>> {
parse_pattern_block_with(data, &mut PatternBudget::default())
}
/// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit.
/// The budget is charged only for patterns that are returned.
pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result<Vec<PsdPattern>> {
let mut left = budget.clone();
let mut r = Rd { b: data, p: 0 };
let mut out = Vec::new();
while r.p + 4 <= data.len() {
@@ -402,10 +436,11 @@ pub fn parse_pattern_block(data: &[u8]) -> Result<Vec<PsdPattern>> {
break;
}
let body = r.take(len)?;
out.push(read_pattern(&mut Rd { b: body, p: 0 })?);
out.push(read_pattern(&mut Rd { b: body, p: 0 }, &mut left)?);
let pad = (4 - len % 4) % 4;
r.p = (r.p + pad).min(data.len());
}
*budget = left;
Ok(out)
}
@@ -442,8 +477,9 @@ pub fn parse_pat_file(data: &[u8]) -> Result<Vec<PsdPattern>> {
let _version = r.u16()?;
let count = r.u32()?;
let mut out = Vec::new();
let mut budget = PatternBudget::default();
for _ in 0..count.min(100_000) {
out.push(read_pattern(&mut r)?);
out.push(read_pattern(&mut r, &mut budget)?);
}
Ok(out)
}
@@ -681,6 +717,54 @@ mod tests {
assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES"))));
}
/// A one-channel gray pattern whose 5x3 channel is padded into a `size`x`size` rectangle.
fn padded_gray_block(size: i32) -> Vec<u8> {
let mut p = pat(8, false);
p.mode = 1;
p.channels.truncate(1);
let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap();
let vma = pattern_vma_rect_offset(4, &p);
set_i32(&mut block, vma + 8, size);
set_i32(&mut block, vma + 12, size);
block
}
#[test]
fn padding_budget_is_shared_by_every_pattern_of_a_parse() {
// Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before
// the fix each pattern got a fresh allowance, so N tiny patterns committed N times the cap.
let one = padded_gray_block(100);
let mut budget = PatternBudget::with_decoded_limit(15_000);
assert_eq!(parse_pattern_block_with(&one, &mut budget).unwrap().len(), 1);
assert_eq!(budget.decoded_left(), 5_000);
let two = [one.clone(), one.clone()].concat();
let mut budget = PatternBudget::with_decoded_limit(15_000);
assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_))));
// A failed parse keeps nothing, so it charges nothing.
assert_eq!(budget.decoded_left(), 15_000);
// Two blocks of one document share a budget too.
let mut budget = PatternBudget::with_decoded_limit(15_000);
assert!(parse_pattern_block_with(&one, &mut budget).is_ok());
assert!(parse_pattern_block_with(&one, &mut budget).is_err());
}
#[test]
fn channels_at_different_depths_are_rejected() {
// The second colour channel claims 8 bits in a 16-bit pattern. It used to be accepted with
// the pattern's depth taken from the last channel, so the result couldn't be written back.
let p = pat(16, false);
let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap();
let first = pattern_vma_rect_offset(4, &p) + 20;
let first_len = u32::from_be_bytes(block[first + 4..first + 8].try_into().unwrap()) as usize;
let second = first + 8 + first_len;
block[second + 8..second + 12].copy_from_slice(&8u32.to_be_bytes());
block[second + 28..second + 30].copy_from_slice(&8u16.to_be_bytes());
let err = parse_pattern_block(&block).unwrap_err().to_string();
assert!(err.contains("different depths"), "{err}");
}
#[test]
fn zero_size_pattern_rectangles_are_rejected() {
let p = pat(8, false);
+53 -9
View File
@@ -81,6 +81,7 @@ fn put_ustr(out: &mut Vec<u8>, s: &str) {
// ---------- descriptor helpers (versions 7/8 and the v6 tail) ----------
/// A number as `i64`; float casts saturate (NaN reads as 0).
fn d_int(d: &Descriptor, key: &str) -> Option<i64> {
match d.get(key)? {
Value::Integer(v) => Some(i64::from(*v)),
@@ -91,6 +92,20 @@ fn d_int(d: &Descriptor, key: &str) -> Option<i64> {
}
}
/// A coordinate, clamped to the `i32` range instead of wrapping.
fn d_i32(d: &Descriptor, key: &str) -> Option<i32> {
d_int(d, key).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32)
}
/// An id. A 32-bit `long` holds the id's bits (ids past `i32::MAX` are written negative); any
/// other number outside the `u32` range is no id at all rather than a wrapped one.
fn d_u32(d: &Descriptor, key: &str) -> Option<u32> {
match d.get(key)? {
Value::Integer(v) => Some(*v as u32),
_ => u32::try_from(d_int(d, key)?).ok(),
}
}
fn d_text(d: &Descriptor, key: &str) -> String {
match d.get(key) {
Some(Value::Text(t)) => t.to_string_lossy(),
@@ -111,7 +126,7 @@ fn d_bool(d: &Descriptor, key: &str) -> bool {
fn d_bounds(d: &Descriptor, key: &str) -> Option<[i32; 4]> {
let Value::Descriptor(b) = d.get(key)? else { return None };
let g = |k: &str| d_int(b, k).unwrap_or(0) as i32;
let g = |k: &str| d_i32(b, k).unwrap_or(0);
// top, left, bottom, right
Some([g("Top "), g("Left"), g("Btom"), g("Rght")])
}
@@ -143,10 +158,10 @@ fn slice_from_descriptor(d: &Descriptor) -> SliceRecord {
_ => [0; 4],
};
SliceRecord {
id: d_int(d, "sliceID").unwrap_or(0) as u32,
group_id: d_int(d, "groupID").unwrap_or(0) as u32,
id: d_u32(d, "sliceID").unwrap_or(0),
group_id: d_u32(d, "groupID").unwrap_or(0),
origin: d_enum(d, "origin").map_or(2, |s| origin_from(&s)),
layer_id: d_int(d, "layerID").map(|v| v as u32),
layer_id: d_u32(d, "layerID"),
name: d_text(d, "Nm "),
kind: d_enum(d, "Type").map_or(1, |s| kind_from(&s)),
// Descriptor bounds are top/left/bottom/right; records use left/top/right/bottom.
@@ -161,10 +176,10 @@ fn slice_from_descriptor(d: &Descriptor) -> SliceRecord {
vertical_align: 0,
color,
outsets: [
d_int(d, "topOutset").unwrap_or(0) as i32,
d_int(d, "leftOutset").unwrap_or(0) as i32,
d_int(d, "bottomOutset").unwrap_or(0) as i32,
d_int(d, "rightOutset").unwrap_or(0) as i32,
d_i32(d, "topOutset").unwrap_or(0),
d_i32(d, "leftOutset").unwrap_or(0),
d_i32(d, "bottomOutset").unwrap_or(0),
d_i32(d, "rightOutset").unwrap_or(0),
],
}
}
@@ -261,8 +276,10 @@ impl SlicesResource {
{
for v in items {
let Value::Descriptor(d) = v else { continue };
// An entry without a usable id belongs to no slice (not to slice 0).
let Some(id) = d_u32(d, "sliceID") else { continue };
let extra = slice_from_descriptor(d);
if let Some(s) = slices.iter_mut().find(|s| s.id == extra.id) {
if let Some(s) = slices.iter_mut().find(|s| s.id == id) {
s.outsets = extra.outsets;
}
}
@@ -435,6 +452,33 @@ mod tests {
assert_eq!(back.slices[2].outsets, [1, 2, 3, 4]);
}
#[test]
fn out_of_range_descriptor_numbers_do_not_wrap() {
// sliceID 2^32+1 used to read as slice 1 and take that slice's outsets.
let wide = |key: &str| Descriptor::new("slice").with("sliceID", Value::LargeInteger((1 << 32) + 1)).with(key, Value::Integer(9));
let mut plain = sample();
for s in &mut plain.slices {
s.outsets = [0; 4];
}
let mut bytes = plain.to_bytes();
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(wide("topOutset"))]))).write(&mut bytes);
let back = SlicesResource::from_bytes(&bytes).unwrap();
assert!(back.slices.iter().all(|s| s.outsets == [0; 4]), "{:?}", back.slices);
// Descriptor form: ids out of the u32 range fall back to 0, coordinates clamp.
let d = wide("leftOutset")
.with("layerID", Value::LargeInteger(-1))
.with("bottomOutset", Value::LargeInteger(1 << 40))
.with("rightOutset", Value::Double(-1e12))
.with("bounds", Value::Descriptor(Descriptor::new("Rct1").with("Top ", Value::LargeInteger((1 << 32) + 5))));
let s = slice_from_descriptor(&d);
assert_eq!((s.id, s.layer_id), (0, None));
assert_eq!(s.outsets, [0, 9, i32::MAX, i32::MIN]);
assert_eq!(s.rect[1], i32::MAX);
// A 32-bit long keeps the id's bits, as `to_descriptor_bytes` writes ids past i32::MAX.
assert_eq!(slice_from_descriptor(&Descriptor::new("slice").with("sliceID", Value::Integer(-1))).id, u32::MAX);
}
#[test]
fn malformed_input_is_an_error() {
let bytes = sample().to_bytes();
+20 -6
View File
@@ -325,13 +325,27 @@ impl TaggedBlock {
}
pub(crate) fn write(&self, out: &mut Vec<u8>, version: Version, alignment: usize) -> Result<()> {
out.put(&self.signature);
out.put(&self.key);
out.put_len(self.data.len() as u64, uses_long_length(version, &self.key))?;
out.put(&self.data);
match &self.padding {
Self::write_parts(out, self.signature, self.key, &self.data, self.padding.as_deref(), version, alignment)
}
/// Writes a block from its parts (no owned [`TaggedBlock`] needed). `padding: None` writes
/// zeros up to `alignment`.
pub(crate) fn write_parts(
out: &mut Vec<u8>,
signature: [u8; 4],
key: [u8; 4],
data: &[u8],
padding: Option<&[u8]>,
version: Version,
alignment: usize,
) -> Result<()> {
out.put(&signature);
out.put(&key);
out.put_len(data.len() as u64, uses_long_length(version, &key))?;
out.put(data);
match padding {
Some(p) => out.put(p),
None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(self.data.len(), alignment))),
None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(data.len(), alignment))),
}
Ok(())
}
+136 -36
View File
@@ -33,7 +33,7 @@ use crate::image_data::ImageData;
use crate::io::{Reader, WriteExt};
use crate::layer::{LayerInfo, LayerMask, mask_parameter_bytes};
use crate::resources::ImageResource;
use crate::tagged::{TaggedBlock, read_blocks, uses_long_length, write_blocks};
use crate::tagged::{TaggedBlock, read_blocks, uses_long_length};
/// The byte order of a TIFF container (and so of the Photoshop data inside it).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
@@ -153,7 +153,7 @@ impl ImageSourceData {
/// dropped because they could not be converted to a little-endian layout.
pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec<u8>, Vec<String>)> {
let version = self.version;
let mut blocks = Vec::with_capacity(self.global_blocks.len() + 2);
let mut body = Vec::new();
if let Some(info) = &self.layer_info {
// Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`.
let pad = info.pad_len(version, 4)?;
@@ -161,21 +161,17 @@ impl ImageSourceData {
data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?;
info.write_unpadded(&mut data, version)?;
data.resize(data.len() + pad, 0);
blocks.push(padded_block(self.layer_info_key, data));
padded_block(self.layer_info_key, data).write(&mut body, version, 4)?;
}
// Blocks without explicit padding are padded to 4 as they are written (alignment 4), so
// the global mask and blocks are written from the model, without a copy of their data.
// The signature stays (a foreign-order marker included).
if let Some(m) = &self.global_layer_mask {
blocks.push(padded_block(*b"LMsk", m.data.clone()));
TaggedBlock::write_parts(&mut body, *b"8BIM", *b"LMsk", &m.data, None, version, 4)?;
}
for b in &self.global_blocks {
let mut b = b.clone();
if b.padding.is_none() {
// Padded to 4 like the others; the signature stays (a foreign-order marker included).
b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]);
}
blocks.push(b);
b.write(&mut body, version, 4)?;
}
let mut body = Vec::new();
write_blocks(&mut body, &blocks, version, 2)?;
body.put(&self.trailing);
let (body, warnings) =
if order.is_little() { transcode::transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little)? } else { (body, Vec::new()) };
@@ -344,6 +340,19 @@ pub mod transcode {
(self.out, self.warnings)
}
/// A walker over `data` (a nested structure) that writes into this walker's output: it
/// takes the buffer, so a nested structure is written once, not copied up per level. Hand
/// the buffer back with [`Self::adopt`] before returning, error or not.
fn nested<'b>(&mut self, data: &'b [u8], depth: usize) -> Tx<'b> {
let r = if self.from.is_little() { Reader::new_little(data) } else { Reader::new(data) };
Tx { r, out: std::mem::take(&mut self.out), from: self.from, to: self.to, version: self.version, warnings: Vec::new(), depth }
}
/// Takes the output buffer back from a [`Self::nested`] walker.
fn adopt(&mut self, child: &mut Tx<'_>) {
self.out = std::mem::take(&mut child.out);
}
fn same_order(&self) -> bool {
self.from == self.to
}
@@ -469,15 +478,15 @@ pub mod transcode {
/// Transcodes the next `len` bytes with `f`; whatever `f` leaves is copied verbatim.
fn region(&mut self, len: u64, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> {
let data = self.r.bytes_u64(len)?;
let mut child = Tx::new(data, self.version, self.from, self.to);
child.depth = self.depth;
f(&mut child)?;
child.rest()?;
if child.out.len() != data.len() {
let start = self.out.len();
let mut child = self.nested(data, self.depth);
let res = f(&mut child).and_then(|()| child.rest());
self.adopt(&mut child);
res?;
if self.out.len() - start != data.len() {
return Err(PsdError::invalid("byte-order conversion changed a structure's length"));
}
self.warnings.append(&mut child.warnings);
self.out.extend_from_slice(&child.out);
Ok(())
}
@@ -500,13 +509,13 @@ pub mod transcode {
len = len.checked_sub(4).ok_or_else(|| PsdError::invalid("length shorter than its own field"))?;
}
let data = self.r.bytes_u64(len)?;
let mut child = Tx::new(data, self.version, self.from, self.to);
child.depth = self.depth;
f(&mut child)?;
child.rest()?;
let start = self.out.len();
let mut child = self.nested(data, self.depth);
let res = f(&mut child).and_then(|()| child.rest());
self.adopt(&mut child);
res?;
self.warnings.append(&mut child.warnings);
self.patch_len(at, long, child.out.len() as u64 + if inclusive { 4 } else { 0 })?;
self.out.extend_from_slice(&child.out);
self.patch_len(at, long, (self.out.len() - start) as u64 + if inclusive { 4 } else { 0 })?;
Ok(())
}
@@ -672,13 +681,12 @@ pub mod transcode {
if !self.from.is_little() && TaggedBlock::is_foreign_order_signature(self.r.peek_rest()) {
let sig = self.r.array::<4>()?;
let mut key = self.r.array::<4>()?;
let long = uses_long_length(self.version, &{
let mut k = key;
if self.to.is_little() {
k.reverse();
}
k
});
// Only reached with `from == Big`: the key is canonical here whatever the
// target order. `TaggedBlock::write` sizes the length field from that same key.
// Reversing it for the target order before the check
// asked about a key `PSB_LONG_KEYS` never contains, so a PSB long key's 8-byte
// length was read as 4 and every later block was copied unconverted.
let long = uses_long_length(self.version, &key);
let len = self.r.len_field(long)?;
let data = self.r.bytes_u64(len)?;
if self.to.is_little() {
@@ -704,8 +712,9 @@ pub mod transcode {
let len_at = self.out.len();
self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] });
let data = self.r.bytes_u64(len)?;
let mut child = Tx::new(data, self.version, self.from, self.to);
child.depth = self.depth + 1;
// The body is transcoded straight into this output, after the length placeholder.
let data_at = self.out.len();
let mut child = self.nested(data, self.depth + 1);
let handled = if child.depth > MAX_DEPTH {
Err(PsdError::LimitExceeded("tagged block nesting too deep"))
} else if data.is_empty() {
@@ -713,16 +722,21 @@ pub mod transcode {
} else {
child.block_body(&key)
};
let rest = if matches!(handled, Ok(true)) { child.rest() } else { Ok(()) };
self.adopt(&mut child);
rest?;
if !matches!(handled, Ok(true)) {
// A body that was not transcoded leaves nothing: it is copied as stored or dropped.
self.out.truncate(data_at);
}
// Padding: the smallest k in 0..=3 after which the next block (or the end) starts.
let rest = self.r.peek_rest();
let pad = (0..=3usize).find(|&k| k == rest.len() || (k < rest.len() && is_sig(&rest[k..], self.from.is_little()))).unwrap_or(0);
let pad_bytes = self.r.bytes(pad)?;
match handled {
Ok(true) => {
child.rest()?;
self.warnings.append(&mut child.warnings);
self.patch_len(len_at, long, child.out.len() as u64)?;
self.out.extend_from_slice(&child.out);
self.patch_len(len_at, long, (self.out.len() - data_at) as u64)?;
self.out.extend_from_slice(pad_bytes);
}
Ok(false) if self.same_order() => {
@@ -2196,6 +2210,25 @@ mod tests {
assert_eq!(ImageSourceData::from_bytes(&big).unwrap().0.global_blocks, back.global_blocks);
}
/// A PSB keeps `Mt16`/`Mt32`/`Mtrn`/`Alph` blocks of unknown layout verbatim, and their
/// length field is 8 bytes (a PSB long key). Writing them into a little-endian TIFF must
/// read that width; the old code looked the key up after reversing it for the target
/// order, which never matches `PSB_LONG_KEYS`.
#[test]
fn psb_foreign_order_long_key_roundtrips() {
let d = ImageSourceData {
version: Version::Psb,
byte_order: ByteOrder::Little,
global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }],
..Default::default()
};
let (little, w) = d.to_bytes(ByteOrder::Little).unwrap();
assert!(w.is_empty(), "{w:?}");
let (back, w) = ImageSourceData::from_bytes(&little).unwrap();
assert!(w.is_empty(), "{w:?}");
assert_eq!(back.global_blocks, d.global_blocks, "the 8-byte length and the bytes survive");
}
#[test]
fn rle_counts_follow_the_container_and_samples_do_not() {
let file = testgen::layered(Version::Psd, ColorMode::Rgb, 16, Compression::Rle);
@@ -2330,4 +2363,71 @@ mod tests {
assert_eq!(again.global_blocks, expected.global_blocks);
assert_eq!(again.layer_tree().len(), file.layer_tree().len());
}
/// Global blocks are padded as they are written, not on a copy: the bytes are those of the
/// former path (each block cloned with explicit padding to 4, written with `write_blocks`).
#[test]
fn global_blocks_written_without_copies_keep_their_bytes() {
for version in [Version::Psd, Version::Psb] {
let mut d = ImageSourceData { version, global_layer_mask: Some(GlobalLayerMask { data: vec![0, 1, 0, 2, 0, 3, 0, 4, 9] }), ..Default::default() };
for n in 0..8u8 {
d.global_blocks.push(TaggedBlock::new(*b"FMsk", (0..n).collect()));
}
let mut explicit = TaggedBlock::new(*b"FMsk", vec![1, 2, 3]);
explicit.padding = Some(vec![]);
d.global_blocks.push(explicit);
let mut odd = TaggedBlock::new(*b"FMsk", vec![1, 2]);
odd.padding = Some(vec![0, 0, 0, 0, 0]);
d.global_blocks.push(odd);
let mut copies = vec![padded_block(*b"LMsk", d.global_layer_mask.as_ref().unwrap().data.clone())];
for b in &d.global_blocks {
let mut b = b.clone();
if b.padding.is_none() {
b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]);
}
copies.push(b);
}
let mut body = Vec::new();
crate::tagged::write_blocks(&mut body, &copies, version, 2).unwrap();
let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE };
assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat());
let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap();
assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat());
}
}
/// Nested structures are transcoded into one shared buffer. A block whose body fails
/// part-way leaves no partial bytes: dropped on the way out (with the enclosing lengths
/// patched as if it had never been there), or kept exactly as stored on the way in.
#[test]
fn a_nested_block_that_fails_part_way_leaves_nothing_behind() {
let clean = sample_layers();
let mut fx = Vec::new();
fx.put_u32(0);
fx.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("Opct", Value::Double(1.0))).to_bytes());
// A whole number of 4-byte units, so dropping it cannot change any padding.
fx.resize(fx.len().next_multiple_of(4), 0);
let mut d = clean.clone();
d.layer_info.as_mut().unwrap().layers[0].blocks.insert(0, TaggedBlock::new(*b"lfx2", fx.clone()));
let (mut little, w) = d.to_bytes(ByteOrder::Little).unwrap();
assert!(!w.iter().any(|w| w.starts_with("lfx2")), "{w:?}");
// An unknown value type after the descriptor's header: parsing fails part-way.
let at = little.windows(4).position(|w| w == b"buod").unwrap();
little[at..at + 4].copy_from_slice(b"????");
let (back, w) = ImageSourceData::from_bytes(&little).unwrap();
assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}");
let kept = &back.layer_info.as_ref().unwrap().layers[0].blocks[0];
assert!(kept.is_foreign_order());
let stored = little.windows(4).position(|w| w == b"2xfl").unwrap() + 8;
assert_eq!(kept.data, little[stored..stored + fx.len()]);
// Big-endian data that fails part-way cannot go into a little-endian file: dropped.
let mut big_bad = d.clone();
let mut broken = fx.clone();
let at = broken.windows(4).position(|w| w == b"doub").unwrap();
broken[at..at + 4].copy_from_slice(b"????");
big_bad.layer_info.as_mut().unwrap().layers[0].blocks[0].data = broken;
let (little, w) = big_bad.to_bytes(ByteOrder::Little).unwrap();
assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}");
assert_eq!(little, clean.to_bytes(ByteOrder::Little).unwrap().0);
}
}
+3 -2
View File
@@ -67,7 +67,7 @@ camera colour tables were copied.
| Format | Status |
|---|---|
| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) |
| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG, lossy JPEG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, gray for CFA, YCbCr-coded RGB for LinearRaw; decoded by `zune-jpeg`, the stored 8-bit values then go through the same LinearizationTable / BlackLevel / WhiteLevel as every other depth) and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) |
| DNG (lossy JPEG, floating point; opcodes other than GainMap; ProfileGainTableMap) | Unsupported / not applied (reported) |
| CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) |
| CR2 sRAW / mRAW | Unsupported |
@@ -123,7 +123,8 @@ smallest preview at least 512 pixels long, fits and develops in one go.
Camera Raw's tuned import fits at the default exposure and as-shot white balance first,
then applies the user's Exposure, Temperature and Tint to that same rendering.
The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280
sensor plane; it removes the border without changing levels, WB or calibration.
sensor plane; the D90's is 4288 × 2848 at (10, 10) in a 4352 × 2868 plane; each
removes the border without changing levels, WB or calibration.
Measured image areas preserve an explicit file crop.
For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded
+40
View File
@@ -127,6 +127,10 @@ const PROFILES: &[Profile] = &[Profile {
/// 4992 x 3280 sensor data, giving the camera JPEG's 4928 x 3264 frame.
/// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the
/// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom).
/// * **D90** (1 file): 10 x 10 pixels in from the left and top of the
/// 4352 x 2868 sensor data, giving the camera JPEG's 4288 x 2848 frame
/// (10 pixels of margin on the right and bottom; the right border reads the
/// camera's flat 255 from column 4310 on).
struct ImageArea {
make: &'static str,
model: &'static str,
@@ -137,6 +141,7 @@ struct ImageArea {
}
const IMAGE_AREAS: &[ImageArea] = &[
ImageArea { make: "NIKON CORPORATION", model: "NIKON D90", size: (4352, 2868), crop: (10, 10, 4288, 2848) },
ImageArea { make: "NIKON CORPORATION", model: "NIKON D5100", size: (4992, 3280), crop: (8, 8, 4928, 3264) },
ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) },
ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) },
@@ -152,6 +157,11 @@ pub(crate) fn apply(s: &mut Sensor) {
s.crop = Rect::new(x, y, w, h);
}
let Some(p) = PROFILES.iter().find(|p| make == p.make && model == p.model && (s.width, s.height) == p.size) else { return };
// A raw with its own colour calibration (a DNG) keeps it: only the measured image area
// above applies. The measured profile stands in for files that carry no matrix themselves.
if !s.color.calibrations.is_empty() {
return;
}
let (x, y, w, h) = p.crop;
s.crop = Rect::new(x, y, w, h);
s.white = [p.white; 3];
@@ -265,6 +275,36 @@ mod tests {
assert_eq!(s.crop, Rect::new(0, 0, 7424, 4924));
}
#[test]
fn d90_image_area_matches_the_camera_jpeg() {
let mut s = nef("NIKON D90", 4352, 2868);
apply(&mut s);
assert_eq!(s.crop, Rect::new(10, 10, 4288, 2848));
// Only the image area is measured: levels, colour and tone stay untouched.
assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty());
assert_eq!(s.white, [16383.0; 3]);
// An explicit crop in the file wins over the measured area.
s.crop = Rect::new(0, 0, 4288, 2848);
apply(&mut s);
assert_eq!(s.crop, Rect::new(0, 0, 4288, 2848));
}
#[test]
fn a_file_with_its_own_calibration_keeps_it() {
// A file that carries its own colour calibration (a DNG, or any calibrated raw) keeps
// it: the measured profile must not override it (only the image area would apply).
let mut s = nef("NIKON D4", 4992, 3292);
s.color = ColorInfo {
calibrations: vec![Calibration { temperature: 6504.0, color_matrix: IDENTITY, forward_matrix: None, camera_calibration: IDENTITY }],
..ColorInfo::default()
};
apply(&mut s);
assert_eq!(s.color.calibrations.len(), 1);
assert_eq!(s.color.calibrations[0].temperature, 6504.0, "the file's own calibration stands");
assert!(s.tone_curve.is_empty() && s.black_point == 0.0);
assert_eq!(s.crop, Rect::new(0, 0, 4992, 3292), "the D4 has no measured image area");
}
#[test]
fn other_cameras_and_sizes_are_left_alone() {
for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] {
+1 -1
View File
@@ -28,7 +28,7 @@
//! Bodies whose full profile is not measured yet can still carry a measured
//! image area (the D800E, the ILCE-7).
//!
//! Decoded today: DNG (uncompressed, lossless-JPEG and JPEG XL, strips and
//! Decoded today: DNG (uncompressed, deflate, lossless-JPEG, lossy-JPEG and JPEG XL, strips and
//! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or
//! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed),
//! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW),
+52 -28
View File
@@ -227,7 +227,7 @@ fn inflate_tile(src: &[u8], need: usize) -> Result<Vec<u8>> {
Ok(out)
}
/// Reads the (uncompressed, lossless-JPEG or JPEG XL) image of a raw IFD.
/// Reads the (uncompressed, deflate, lossless-JPEG, lossy-JPEG or JPEG XL) image of a raw IFD.
pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayout) -> Result<Plane> {
let width = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("raw image has no width"))? as usize;
let height = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("raw image has no height"))? as usize;
@@ -256,6 +256,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou
limits.check(width as u64, height as u64, samples as u64 * 2)?;
let segments = segments(t, ifd, width, height)?;
let le = t.le;
let tiled = t.tag_uint(ifd, tag::TILE_WIDTH).is_some();
let decoded: Vec<Result<Vec<u16>>> = par::map(segments.len(), |i| {
let s = &segments[i];
let rows = s.h.min(height - s.y);
@@ -286,37 +287,12 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou
}
// A JPEG XL segment codes only the rows inside the image (strips) or the whole tile.
JPEG_XL => {
let coded_rows = if t.tag_uint(ifd, tag::TILE_WIDTH).is_some() { s.h } else { rows };
let coded_rows = if tiled { s.h } else { rows };
let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, limits)?;
v.resize(s.w * s.h * samples, 0);
Ok(v)
}
34892 => {
// Adobe DNG compression 34892: baseline JPEG tiles (lossy, 8-bit). The tile is
// a complete JPEG stream: a CFA part decodes to gray samples, a 3-sample
// LinearRaw part holds YCbCr, which zune-jpeg converts to RGB. The header is
// decoded and size-checked before any pixel allocation, so a hostile tile
// cannot size the decoder's buffers (up to 16384x16384) with a fake SOF.
let out = match samples {
1 => zune_core::colorspace::ColorSpace::Luma,
3 => zune_core::colorspace::ColorSpace::RGB,
n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))),
};
let options = zune_core::options::DecoderOptions::default().jpeg_set_out_colorspace(out);
let mut dec = zune_jpeg::JpegDecoder::new_with_options(zune_core::bytestream::ZCursor::new(src), options);
dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?;
let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?;
if (f.width as usize, f.height as usize) != (s.w, rows) {
return Err(RawError::malformed(format!("lossy JPEG tile is {}x{}, expected {}x{}", f.width, f.height, s.w, rows)));
}
let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?;
let need = s.w * rows * samples;
if decoded.len() != need {
return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len())));
}
// The 8-bit samples widen to the document's 16-bit range (0xFF -> 0xFFFF).
Ok(decoded.into_iter().map(|b| u16::from(b) * 257).collect())
}
34892 => lossy_jpeg(src, s, rows, samples, bits, tiled, limits),
// Compression 7 (lossless JPEG): the frame the entropy stream declares is capped
// by the budget, not only by the segment, and the `Quads` scatter buffer below
// comes out of the same budget. `max_alloc` counts bytes, a sample is two.
@@ -401,6 +377,54 @@ pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Resu
Ok(out)
}
/// Decodes one segment of a lossy DNG (Adobe DNG compression 34892): a complete baseline-DCT
/// JPEG stream (ITU-T T.81), 8 bits per sample, as DNG 1.4 defines it. A CFA segment is a
/// one-component (gray) JPEG; a 3-sample LinearRaw segment is a three-component one in YCbCr,
/// which the JPEG decoder turns back into the stored R, G, B. The decoded 8-bit values are the
/// stored sample values: the LinearizationTable, BlackLevel and WhiteLevel apply to them later,
/// as for every other compression, and WhiteLevel defaults to 255 for 8-bit data.
///
/// `rows` is the number of segment rows inside the image. A tile is always coded at the full
/// tile size (edge tiles are padded); a strip is coded at its row count (the last strip is
/// shorter). Either height is accepted, the width must be the segment's. The frame header is
/// checked before any pixel is decoded, and the decoder's own size cap is the segment, so a
/// hostile SOF cannot size a buffer larger than the tile the IFD declares (which `limits` has
/// already charged).
fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, tiled: bool, limits: &Limits) -> Result<Vec<u16>> {
use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
if bits != 8 {
return Err(RawError::unsupported(format!("lossy DNG with {bits}-bit samples (lossy JPEG tiles are 8-bit)")));
}
let out = match samples {
1 => ColorSpace::Luma,
3 => ColorSpace::RGB,
n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))),
};
let options = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(s.w).set_max_height(s.h);
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), options);
dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?;
let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?;
let (fw, fh) = (usize::from(f.width), usize::from(f.height));
let coded_rows = if tiled { s.h } else { rows };
if fw != s.w || (fh != coded_rows && fh != rows) {
return Err(RawError::malformed(format!("lossy JPEG tile is {fw}x{fh}, expected {}x{coded_rows}", s.w)));
}
if usize::from(f.components) != samples {
return Err(RawError::malformed(format!("lossy JPEG tile has {} components, expected {samples}", f.components)));
}
let need = fw.checked_mul(fh).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?;
limits.check(fw as u64, fh as u64, samples as u64 * 2)?;
let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?;
if decoded.len() != need {
return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len())));
}
let mut v: Vec<u16> = decoded.into_iter().map(u16::from).collect();
// A strip coded at its own row count still fills the segment's declared height.
let full = s.w.checked_mul(s.h).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?;
v.resize(full, 0);
Ok(v)
}
/// Reverses TIFF predictor 2 (horizontal differencing) on interleaved samples: each sample
/// gains its channel's predecessor in the row, wrapping at the sample's container (8 or 16 bits).
fn undelta(v: &[u16], plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec<u16> {
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more