Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ce2a1e337a Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:39:21 -04:00
Dylan M. Taylor 6649b78526 Use chrono instead of hand-rolled UTC conversion (#8234)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the civil_from_days copies behind the app's log timestamp, the
Measurement Log's date/time, and the perf report date with chrono as a
workspace dependency (reusing the version already in the tree). Output formats
are unchanged and pinned by the existing tests. AGENTS.md now says time goes
through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked. logging::timestamp and analysis_cmds::now_iso now read the duration
since the epoch and go through the fallible DateTime::from_timestamp, reading
an out-of-range or pre-epoch clock as the epoch as the old saturating code
did. All three ISO helpers (timestamp, now_iso, perf::iso_utc) write the year
with {:04} rather than %Y, which prefixes a '+' past year 9999.

Tests cover the far-future and year-10000 cases for timestamp and iso_utc.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any
instant past chrono's year range to 1970-01-01, changing output the old
hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s
was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain
with a cycle-based helper that routes the calendar through chrono (the
calendar repeats every 400 years = 146_097 days, so month/day/time come from
chrono and only the year is carried in i128, keeping every i64 exact).

- apps/photocraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/engine/src/analysis_cmds.rs: extract pure `iso_from_unix` (cycle
  based) from `now_iso` so the conversion is tested directly.
- xtask/src/perf.rs: `iso_utc` cycle-based, accepts the full u64 range the
  old code did.

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  iso_from_unix 10_000_000_000_000 -> 318857-05-20T17:46:40Z,
          -10_000_000_000_000 -> -314918-08-13T06:13:20Z, i64 extremes
  iso_utc u64::MAX -> 584554051223-11-09T07:00:15Z (was epoch),
          10_000_000_000_000 -> 318857-05-20T17:46:40Z

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 13:54:10 -04:00
aiongg 46394c5976 Merge pull request #8644 from aiongg/tests-one-binary-r4
Build each crate's integration tests as one binary
2026-10-22 13:53:15 -04:00
aiongg ffa400c754 Keep tests correct and docs current with one test binary per crate
- gpu: the test modules share one GPU_LOCK, and timing and vector_mask
  take it too, so one process drives one wgpu device at a time.
- ui-egui: the wgpu tests in pattern_preview_canvas, adjust_preview_gpu,
  marquee_autoscroll and layout_perf take gpu_lock().
- affinity compound_limits: the child process selects its test by the
  module path (compound_limits::<name>); with --exact, the bare name
  matches no test and the child passes without running the check.
- xtask scorecard reads crates/io/tests/it/corpus.rs.
- Docs and comments name the new test paths and the --test it filters.
2026-10-22 16:55:34 +00:00
aiongg a6428d3dfb Build each crate's integration tests as one binary
Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs; helper modules under tests/common move with them.

These keep their own binary (own process):
- engine panic_hunt: a process-wide panic hook counts every panic.
- engine text_gamma: changes the process-wide text gamma.
- engine smart_filter_stages, mask_cache_purge: assert on global caches.
- codecs pnm_peak_memory: reads the process's peak RSS.
- photocraft mac_menu_appkit: harness = false.
2026-10-22 16:55:34 +00:00
aiongg 6d807ba34c Merge pull request #8577 from aiongg/r4
Backlog batch: 271 reviewed contributor PRs
2026-10-22 12:53:31 -04:00
aiongg fa010ff7c3 Integration: regenerate docs/scorecard.md 2026-10-22 16:03:31 +00:00
aiongg 1370c7a642 Integration: #7811 cargo fmt on process_outputs 2026-10-22 15:47:53 +00:00
6d3092f243 Integration: merge main, resolve conflicts, JPEG Options docs and encoder test (#7883)
Merges main (8f09d122d). Conflicts: the replay_save doc comment (main added
file.export.exportAs replay) and the status lines and history rows of
docs/file-format-parity.md and docs/gaps.md; both sides kept.

The docs called FILE-215-19 done and said only Save As and Save a Copy ask.
Photoshop's JPEG Options also has Matte, the progressive scan count
(Scans 3-5) and the size preview, none of which is offered, and Baseline
Optimized writes the same file as Baseline for RGB (4:2:0). The row is now
partial with those gaps named, and the docs say File > Save asks too.
docs/scorecard.md is regenerated.

Adds jpeg_options_ui::tests::the_answer_reaches_the_encoder. Every UI test
used a fake exporter, so dropping `opts.encode.jpeg_format` from
ExportSettings::io_options (the desktop and web save path) left them all
green; this test writes a real JPEG through io_options and fails without it.

Co-authored-by: Michael Higgins <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:45:30 +00:00
a74b9a72a1 Integration: merge main, resolve conflicts (#6793)
The 20 i18n catalogs: main and the PR both appended a block at the end;
both kept, main's first. docs/gaps.md: main's text with the PR's
TYPE-218-2 change applied (G8 Missing, Evidence 5 done / 4 partial /
0 missing, revision row), dated 2026-10-22. docs/scorecard.md
regenerated with cargo xtask scorecard.

The TYPE-218-2 note now also says PSD doesn't store the paragraph
direction (crates/text psd.rs maps only the vertical WritingDirection),
so a direction set with the new buttons reopens from a PSD as Auto.

docs/localization-parity.md: the Arabic row said paragraph direction has
no UI (TYPE-218-2); it now has the buttons, the RTL caret is still missing.

Co-authored-by: tareqziad1998 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:44:17 +00:00
a2a6f1e13f Integration: Zoom Resizes Windows shows the whole image, clear of the scrollbars (#7662)
A Zoom tool click or wheel zoom keeps the clicked spot under the pointer, so
the view centre moves off the image's middle. The window then grew to the
image's size around that off-centre view: a 400x300 image zoomed to 200% about
(50, 50) showed grey on the left and top and lost its right and bottom edges.
The size also left out the 12 pt scrollbars, which are drawn over the canvas
and hid the image's right and bottom edges.

The window now makes room for the scrollbars, and a window that grows around
the whole image centres it in the area the scrollbars leave free (any flip,
rotation, pixel aspect and display scale). A window the screen caps keeps the
zoomed spot where it is. Scrubby zoom and the Animated Zoom hold re-anchor the
view only when the zoom changes, so their release frame and the frames at the
zoom limit keep that centred view. A maximized or full-screen window keeps the
size the user gave it.

Co-authored-by: Nguyễn Minh Long <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:42:54 +00:00
893b54ea8c Integration: keep the font menu's arrows walking with recent fonts (#3559)
Each arrow step in the open font menu applies its family, the options bar,
Character and Glyphs panels record it as the most recent font, and the next
frame hoisted it to row 0: with Recent Fonts on (default 10), Down then
alternated between two families and Up stayed on the first row (CodeRabbit's
open thread on #3559). The menu now keeps the recent group it opened with.

As in Photoshop, the recent families now head the menu in their own group
above a divider and keep their alphabetical place below it, instead of
moving out of it. The arrows start from the current family's alphabetical
copy (or the row the last arrow chose), and only that row is marked.

Tests: open_menu_arrows_walk_on_through_recent_fonts,
options_bar_menu_arrows_walk_on_while_recording_recent_fonts and
open_menu_on_a_recent_font_steps_through_the_alphabet fail without the
fix; recent_fonts_appear_first_in_the_menu pins the group, divider and
alphabetical copies.

Co-authored-by: Reynold Hu <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:42:42 +00:00
81925d7769 Integration: Raw Defaults resolution on Revert, Batch, Stack, headless open and CLI convert (#7811)
The resolution preference reached File › Open, but the other ways a raw
becomes a document of its own still opened it at 72 ppi: File › Revert
(a raw opened at 240 dropped to 72), Batch / Image Processor / Lens
Correction batch (a raw saved as PSD came out at 72 while Open + Save
gave 240), Load Files into Stack, the headless MCP `doc.open`, and the
CLI (`convert` at 72 while `run`/`info`, which open headless, now take
the default 300). They now apply the same `raw_defaults` rule; Batch
passes the user's value into its scratch sessions.

Tests that fail without this change (72 vs 240/300):
revert_batch_and_stack_open_a_raw_at_the_raw_defaults_resolution
(engine, one assertion per path), doc_open_gives_a_raw_the_raw_defaults_resolution
(automation), convert_opens_a_raw_at_the_raw_defaults_resolution (CLI).

Co-authored-by: Korg1ch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:42:42 +00:00
b526378de4 Integration: 32-bit Hue/Saturation with Photoshop's saturation factor, finite at lightness 1 (#5781)
The 32-bit branch cut the slider to floor(|s| * 255) / 255, which only matches
Photoshop near 25 and 50 %: measured at every whole percent from -100 to +100
on a 32-bit document, Photoshop lowers by 1 - floor(|s| * 256) / 256 and raises
by 255 / (255 - floor(s * 254)) taken to 1/256 and capped at 32767/256 (201 of
201 exact). +100 gave 512x where Photoshop gives 128x, +90 9.8x for 9.44x.

It also kept the HSL saturation unclipped, which is inf at HSL lightness 1 and
0 and negative above 1: (2, 0, 0), red at +1 stop, turned NaN with any edit,
Hue +40 alone included, where Photoshop gives (2, 1.333, 0). The 32-bit branch
now scales the chroma around the lightness instead (same result in gamut),
on the CPU and in the shader, in whole numbers on both. Colorize clamps its
saturation before the 32-bit scaling, so a PSD's out-of-range value renders
the same on the GPU.

Tests (fail without this): thirty_two_bit_saturation_steps_match_photoshop,
thirty_two_bit_hue_saturation_keeps_bright_colours, the GPU parity
hdr_hue_saturation_bright_colours. Photoshop-oracle floor 188 -> 190 of 258,
measured after merging main.

Co-authored-by: Timothy Daly <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:42:03 +00:00
f1d947de8a Integration: test that a and b round after the opacity mix (#5771)
The PR's test checks L at full opacity only, so it still passes when a and b are left unrounded or
the rounding moves before the opacity mix, or truncates. New test: Curves taking a 140 to 151.4 at
50% opacity in an 8-bit Lab document must show a 146 (145.7 unrounded, 145.5 rounded before the
mix, 145 truncated: all three fail).

Co-authored-by: timothydaly1 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:41:21 +00:00
dff8bfd0e4 Integration: a unit per field, no Percent, not remembered, as in Photoshop (#7922)
Photoshop's Create Rectangle switches only the right-clicked field
(Width 35.28 mm, Height still 100 px), lists the six length units
without Percent, and opens in pixels again after OK. The PR shared one
unit between both fields, offered Percent, and saved the unit on every
OK, so after one use with the rulers in px the dialog stayed in px even
after the rulers changed to cm, though no unit was ever picked.

Now each field has its own widthUnit / heightUnit (set only by the
menu, never remembered), and the menu is rulers::unit_menu_of with
LENGTH_UNITS. The new test right-clicks the real field and clicks the
menu item through egui input.

Co-authored-by: Korg1ch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:41:21 +00:00
34c9d0ea4d Integration: History follows the history, not every revision, and never moves a visible row (#5940)
The panel scrolled with `scroll_to_rect(current, Some(Align::BOTTOM))` on every revision
change. In egui an explicit alignment always realigns, and the revision also changes when
nothing in the history does (selecting a layer, a channel eye). So clicking a visible older
state slid it to the bottom under the pointer, Undo hid the greyed redo states, a layer click
threw a user who had scrolled up back to the bottom, and selecting a snapshot scrolled it out
of view.

Now the panel follows the history position (document, past/redo counts, current name, and the
newest past state, which also changes at the state limit), skips it while a snapshot is
selected, and scrolls without alignment, so only a hidden current row moves into view. New
tests: a clicked visible state stays put, Undo keeps the redo state in view, a layer click keeps
the scroll, a selected snapshot stays in view, a new state at the limit is revealed; the first
three fail on the PR head, the others without the snapshot guard / the newest-state key.

Co-authored-by: nrq6993-a11y <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:41:21 +00:00
aiongg 64fb74f1e7 Integration: #8143 the branchless floor test accepts any NaN for NaN inputs
MSVC's floor quiets a signalling NaN while branchless_floor keeps its payload, so the Windows job failed the bitwise compare; every other input still compares bit for bit.
2026-10-22 15:40:11 +00:00
aiongg 753b910f5d Merge PR #5988: Raw: a Nikon maker note's ColorSpace sets the preview colour space (#5987) (new head, a rebase of the merged one; its changes are already in) 2026-10-22 15:40:10 +00:00
aiongg 884ea76691 Merge PR #5980: Raw: the Nikon D4 white level follows the sample depth (#5978) (new head, a rebase of the merged one; its changes are already in) 2026-10-22 15:40:09 +00:00
aiongg 22d912a108 Integration: #8142 #8535 Quick Export accepts a .exr path
Main's extension check (#8519) refused .exr, the format #8142 adds to Export Preferences; the path's extension now may also name EXR.
2026-10-22 15:40:08 +00:00
aiongg add0fbc706 Integration: merge main (through #8562)
contributors/people.toml: kept both sides' new entries (r4's list and main's benandez).
2026-10-22 15:40:05 +00:00
aiongg 4819f5b7d4 Merge PR #8372: Compose: effect maps that don't fit in the effect cache are built over the render rect, not rebuilt whole on every render (#8330) (new head) 2026-10-22 15:40:02 +00:00
aiongg 9cba44307d Merge PR #8236: Add interactive drag positioning to print preview (new head)
crates/ui-egui/src/file_ui.rs: kept r4's print_params and added the new head's doc comment on print_confirm.
2026-10-22 15:40:00 +00:00
aiongg 4a9034a762 Merge PR #8168: File names: keep Windows device names (nul, con, com1…) off the disk (new head)
The new head only merges main; crates/engine/src/file_cmds/tests.rs conflicted on the sanitize test's position, kept r4's single copy.
2026-10-22 15:39:57 +00:00
aiongg eb199bcefe Merge PR #7721: fix(actions): refuse lossy fractional Crystallize ATN export (new head) 2026-10-22 15:39:55 +00:00
aiongg fb05540774 Merge PR #6452: Hand tool: hold Ctrl/Cmd to zoom in, Alt to zoom out (#6434) (new head) 2026-10-22 15:39:53 +00:00
aiongg 934eb71d84 Integration: merge main
crates/engine/src/file_cmds.rs: batch keeps the save-point closure (takes the output path) and also copies the session's authorize hook into each scratch session, as main now does.
crates/raw/src/sensor.rs: both sides cap the memory of retained tiles; kept the existing retained_segment_bytes check (checked multiplication, same limit) and dropped main's duplicate inline loop.
2026-10-22 15:39:51 +00:00
aiongg f618cdea29 Integration: #8371 the .atn corpus round trip merges the per-property Sets that Save now writes 2026-10-22 15:39:49 +00:00
aiongg e6f7af8ce7 Integration: #5566 the panel-defaults harness waits for the font scan 2026-10-22 15:39:47 +00:00
aiongg af12584f47 Integration: #6974 depth labels and delete-layer prompt in hi.tsv 2026-10-22 15:39:45 +00:00
aiongg dc6fe754a8 Integration: #6186 New Layer during a background transform commit expects main's refusal, then retries 2026-10-22 15:39:42 +00:00
aiongg dd2cba6e81 Integration: #7325 #6452 Command-click under Curves adds its point with the Hand tool, not a Hand-tool zoom 2026-10-22 15:39:40 +00:00
aiongg fafbb4bbdf Integration: #7388 #6936 the Crop popup key tests make the ratio-fitted frame a drawn one first 2026-10-22 15:39:38 +00:00
aiongg 63de4df31b Integration: #8030 #5571 the group indent test opens the group, which Group Layers creates collapsed 2026-10-22 15:39:36 +00:00
aiongg f905c72434 Integration: #8162 #7256 the Brushes tab test waits for the window to settle after the preview-size slider row 2026-10-22 15:39:34 +00:00
aiongg f88e1c988d Integration: #4751 #3283 the drawn-filter marker takes the document id, as filter_rows now gets the document 2026-10-22 15:39:32 +00:00
aiongg 9cb9535a95 Integration: #4315 DICOM gets its own File > Open filter 2026-10-22 15:39:30 +00:00
aiongg 2496742eaa Integration: merge main
Merges main through #3283 (Smart Filters: distinct editors, shared
disclosure and stable alpha).

filter_dialog.rs: main's split of the preview into is_smart_edit,
smart_preview_factor and scale_preview_params is kept. The plug-in branch
from #7524 (plugin_cmds::scale_for_preview scales only declared pixel
lengths) moved into scale_preview_params, so it applies to a plug-in run
on its own and to a plug-in recorded as a smart filter.

smart_ui.rs open_editor: main's smart_filter_dialog::target flow and the
shared adjust/filter dialog opening are kept; the Liquify re-open with
recorded strokes (r4) stays ahead of it. The old per-adjustment
edit_filter call is dropped with main's rewrite.
2026-10-22 15:39:28 +00:00
aiongg dc6c78e124 Integration: #6974 the mixer brush strings in hi.tsv
The mixer brush test needs every registered language to translate its five strings; the catalog is AI-drafted and not natively reviewed.
2026-10-22 15:39:26 +00:00
aiongg 53cdc3cf16 Integration: #6974 #8236 catalog rows filled with English for keys the coverage tests require 2026-10-22 15:39:23 +00:00
aiongg 8e206e60b0 Integration: #5864 #5993 the DDS level cut-out test sets the level's row pitch 2026-10-22 15:39:21 +00:00
aiongg 3f9d2aaf2b Integration: #5566 #6710 the pre-styling tests no longer expect the hidden Hyphenate control
#6710 hides Hyphenate while layout does not hyphenate, so the Character panel has no control to find or click.
2026-10-22 15:39:19 +00:00
aiongg e846b64262 Integration: #7593 the plain crop round trip carries no deleteCroppedPixels key
The exporter leaves out an unset Crop delete (see the exporters' defaults note), so the imported step has none either.
2026-10-22 15:38:58 +00:00
aiongg 39dd2eda12 Integration: #7101 the Apply Layer Comp event is registered once, by the name-only mapping
main already registered an applyComp event that accepted any reference class; the first match won, so #7101's name-only mapping never ran.
2026-10-22 15:38:56 +00:00
aiongg 322a29a744 Integration: #8087 liquify source footprint test uses as_chunks_mut 2026-10-22 15:38:54 +00:00
aiongg f42dc7f3dd Integration: #7800 #6328 GuideDrag test literals take over_ruler, and the two rulers canvas_harness helpers are told apart 2026-10-22 15:38:52 +00:00
aiongg 386ebe736b Integration: #8304 #7262 layer_props_tests compare with the optional layer ref 2026-10-22 15:38:50 +00:00
aiongg d87a169738 Integration: cargo fmt 2026-10-22 15:38:48 +00:00
aiongg fe220983a7 Integration: regenerate docs/scorecard.md 2026-10-22 15:38:46 +00:00
aiongg 87d930997b Integration: #7611 smart brush export refuses steps with refine strokes 2026-10-22 15:38:44 +00:00
aiongg 8d0f920ea6 Integration: #7574 the lift outline allocates fallibly 2026-10-22 15:38:42 +00:00
aiongg 95db2fe5eb Integration: #7968 #7262 groupLayers From uses the optional layer ref 2026-10-22 15:38:40 +00:00
aiongg 42efb8d777 Merge PR #7530: Marching ants in device pixels, like the reference app (#7489)
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	docs/ui-parity.md
2026-10-22 15:38:38 +00:00
aiongg 7d0cb444d6 Integration: track t13
# Conflicts:
#	contributors/people.toml
#	docs/ui-parity.md
2026-10-22 15:38:35 +00:00
aiongg 576ba0e02b Integration: track t12
# Conflicts:
#	contributors/people.toml
#	crates/compose/src/lib.rs
#	crates/compose/src/tests.rs
#	crates/engine/src/print_cmds.rs
#	crates/engine/src/print_cmds/tests.rs
#	docs/target-app-parity.md
2026-10-22 15:38:33 +00:00
aiongg 5857afd121 Integration: track t11
# Conflicts:
#	apps/photocraft-cli/src/lib.rs
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	docs/gaps.md
2026-10-22 15:38:31 +00:00
aiongg 71991b0a03 Integration: track t10
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/prefs.rs
#	crates/ui-egui/src/layer_style/contour.rs
#	docs/ui-parity.md
2026-10-22 15:38:28 +00:00
aiongg 1dc684a2b6 Integration: track t09
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/atn_cmds/tests.rs
#	crates/engine/src/file_cmds.rs
#	crates/engine/src/jobs.rs
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-22 15:38:26 +00:00
aiongg f72c3c2187 Integration: track t08
# Conflicts:
#	crates/ui-egui/src/camera_raw_ui.rs
#	crates/ui-egui/src/jobs_ui.rs
#	crates/ui-egui/src/widgets.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-22 15:38:23 +00:00
aiongg 446b74a8de Integration: track t07
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	crates/ui-egui/src/layer_shortcut_tests.rs
2026-10-22 15:38:21 +00:00
aiongg ba120dc48e Integration: track t06
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/tr.tsv
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-22 15:38:19 +00:00
aiongg 9c7cc91f82 Integration: track t05
# Conflicts:
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/shortcuts.rs
#	docs/ui-parity.md
2026-10-22 15:38:18 +00:00
aiongg 1f0177f23c Integration: track t04
# Conflicts:
#	ATTRIBUTION.md
#	contributors/people.toml
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	docs/gaps.md
2026-10-22 15:38:17 +00:00
aiongg 37dedcff38 Integration: track t03
# Conflicts:
#	docs/ui-parity.md
2026-10-22 15:38:15 +00:00
aiongg 4d1b922ddf Integration: track t02
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/file_cmds/tests.rs
#	crates/engine/src/print_cmds/tests.rs
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-22 15:38:14 +00:00
aiongg 82cf3c828c Integration: track t01
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-22 15:38:14 +00:00
aiongg 0e80156397 Integration: track t16
Resolutions: CLI usage, flag lists and flag parsing carry both --tiff-compression (t15) and
--jpeg-format (t16); file.saveACopy, batch, imageProcessor and layersToFiles param specs carry
both tiffCompression and jpegFormat; file_cmds keeps tiff_compression_param and
jpeg_format_param; atn_cmds keeps flag_set/applies_at_canvas_bounds (t15) and UNHOLDABLE (t16),
edit.transform uses the pixel_target guard (t15) with layer_ref(p)? (t16); ui-egui open paths
build ImportOptions (t16) and apply raw_defaults (t15); docs headers/rows unioned; atn tests keep
both sides; tr.tsv "Keep Using CPU" kept once.
2026-10-22 15:38:13 +00:00
aiongg 3d1a4d6679 Integration: track t15
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	docs/file-format-parity.md
#	docs/ui-parity.md
2026-10-22 15:38:12 +00:00
aiongg e1d2596bd8 Integration: track t14 2026-10-22 15:38:11 +00:00
aiongg b466ad3fc3 Integration: the #6158 test passes the button #6833 added to the test helper 2026-10-22 15:37:51 +00:00
aiongg c74d5a8abd Merge PR #8315: fix(ui): pin the document tabs' » button to the strip's right end (part of #8290) 2026-10-22 15:37:50 +00:00
aiongg 1a02ccf897 Merge PR #8238: CPU canvas: a large document shows its own pixels at 100% (#7568) 2026-10-22 15:37:50 +00:00
aiongg 70eaa052c3 Merge PR #8223: Alt + wheel zooms while Alt is held, even when the modifier state misses it (#8049) 2026-10-22 15:37:49 +00:00
aiongg 33ce24eabe Merge PR #8141: Panels: the dock can be dragged as wide as the window allows, not only 520 points (#7896) 2026-10-22 15:37:49 +00:00
aiongg da6e925017 Merge PR #8068: Page Up/Down, Home and End scroll the canvas, as in the reference app (#8057) 2026-10-22 15:37:49 +00:00
aiongg 3db630feb4 Merge PR #8029: Snapping: artboard edges and centres are snap targets (#8016)
crates/engine/src/snap.rs: the PR's artboard branch kept, with main's target_rect_of(doc, l) signature in the following branch.
2026-10-22 15:37:48 +00:00
aiongg 58a9dcc510 Merge PR #7957: Fix dropdown menus opening behind modal dialogs on macOS trackpad tap (#7113) 2026-10-22 15:37:48 +00:00
aiongg bc94175a99 Merge PR #6833: Title bar: only the primary button starts a window drag (#6729)
crates/ui-egui/src/panels.rs: main's start_os_move call (from #6158) under the PR's drag_started_by(Primary) condition.
crates/ui-egui/src/titlebar.rs: same, in the modal drag overlay.
2026-10-22 15:37:47 +00:00
aiongg b8605f8447 Merge PR #6826: Don't pan the canvas when a dialog's title bar is dragged (#6810) 2026-10-22 15:37:46 +00:00
aiongg a7c80a83d2 Merge PR #6791: Intel Iris Xe on DX12: no more red blocks in brush previews and tool icons (#6790) 2026-10-22 15:37:46 +00:00
aiongg a97dcfcb9e Merge PR #6480: Ctrl+Space and Alt+Space zoom the image under an open dialog
crates/ui-egui/src/canvas.rs: kept main's curve_point_press and the hand condition that uses it, then the PR's held-zoom block with the existing pan_delta branch as its else-if.
2026-10-22 15:37:45 +00:00
aiongg 1693d0fe4f Merge PR #6380: Make brush cursors follow pen orientation and projection 2026-10-22 15:37:45 +00:00
aiongg 6c58aab811 Merge PR #6328: Guides: a guide let go over either ruler is removed, not only off the canvas (#6286)
docs/ui-parity.md: header line kept as on main with the PR's #6286 item added once; both change-log rows kept.
2026-10-22 15:37:44 +00:00
aiongg 3bb1b49457 Merge PR #6263: fix(ui): Paths panel footer: cut-off hover highlight and scroll jiggle (part of #6238) 2026-10-22 15:37:44 +00:00
aiongg c5abd43a22 Merge PR #6158: Title bar: a window move the window manager finished no longer blocks the next move or resize (#5725)
contributors/people.toml: union of both sides' entries.
2026-10-22 15:37:43 +00:00
aiongg bf687928c2 Merge PR #5936: fix(ui): ensure turning View -> Snap off disables all snapping (#5782) 2026-10-22 15:37:43 +00:00
aiongg e1465cec6d Integration: #6837 hue_color and the Hue/Saturation spectrum use photocraft_color's hsb_to_rgb (the picker's own copy is gone on main) 2026-10-22 15:37:42 +00:00
aiongg 8600ae79b0 Merge PR #5631: Workspace: list saved workspaces in Window > Workspace and the switcher (#5610) 2026-10-22 15:37:42 +00:00
aiongg 40587f17e9 Merge PR #8219: Color Lookup lists the LUTs in a Preferences folder (#6558)
crates/ui-egui/Cargo.toml: kept main's photocraft-psd dev-dependency (testgen feature) and added the PR's tempfile dev-dependency
docs/scorecard.md: ours
2026-10-22 15:37:41 +00:00
aiongg 15a2ac3456 Merge PR #8158: fix(swatches): a CMYK swatch keeps its inks as the foreground (#7474) 2026-10-22 15:37:40 +00:00
aiongg bf34ae5006 Merge PR #8155: fix(info): Info panel CMYK uses the Color Picker's colour-managed conversion (#8101) 2026-10-22 15:37:40 +00:00
aiongg fa09db7d21 Merge PR #8091: Reject '+' signs in color picker hex field 2026-10-22 15:37:39 +00:00
aiongg aba7e03d60 Merge PR #8083: Write DOMAIN_MIN/DOMAIN_MAX in the .cube writer
crates/cms/src/lutfile.rs: both PRs added a test at the same place; kept both whole, each with its own closing brace
2026-10-22 15:37:38 +00:00
aiongg 0974ee5394 Merge PR #8023: Keep '#' inside quoted .cube TITLE when parsing 2026-10-22 15:37:38 +00:00
aiongg 4d42de4a80 Integration: #6963 test comment says the field shrinks to leave room for the readout 2026-10-22 15:37:37 +00:00
aiongg 9172ad7525 Merge PR #7215: Corrective Camera Raw - fix(import/camera-raw): preserve document identity across asynchronous opens and re-develop 2026-10-22 15:37:37 +00:00
aiongg 7660169e54 Merge PR #6963: Color panel: keep hex/RGB readout visible in short docks (#6885) 2026-10-22 15:37:37 +00:00
aiongg 728620951f Merge PR #6942: Color Picker: update the colour on press, not on release (#6937) 2026-10-22 15:37:36 +00:00
aiongg fa0c91eefc Merge PR #6909: perf(camera-raw): develop the preview off the UI thread (#5964) 2026-10-22 15:37:36 +00:00
aiongg 7e6027f71d Merge PR #6837: Hue/Saturation: hue tracks and spectrum bars use full-strength sRGB hues, one shared hue_color (#6540) 2026-10-22 15:37:36 +00:00
aiongg 57409e3670 Merge PR #6456: perf(quantize): Indexed Color palette build 8× faster, bit for bit the same 2026-10-22 15:37:36 +00:00
aiongg bec1d5fd23 Merge PR #6409: fix(raw): size bigtiff long8, slong8 and ifd8 entries as 8 bytes 2026-10-22 15:37:36 +00:00
aiongg a9b0fe9f40 Merge PR #5927: Fix Exposure value-field steps when decimals are set (#5914) 2026-10-22 15:37:36 +00:00
aiongg d1cd1387a1 Integration: fix the two merged tests that no longer compile against main (StrokeFx overprint, per-tool Sample All Layers) 2026-10-22 15:37:35 +00:00
aiongg dc0094fa78 Merge PR #5781: 32-bit Hue/Saturation as the reference app does it: unclipped saturation and Colorize (two rgb32 oracles pass)
crates/io/tests/corpus.rs: kept main's pass_floor 188 (the PR's 187 would lower it; not re-measured here)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
2026-10-22 15:37:35 +00:00
aiongg 5ecfa852f4 Merge PR #5771: Lab: adjustment results are whole samples, as the reference app stores them (lab8 curves passes)
crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
2026-10-22 15:37:35 +00:00
aiongg d4a346e169 Merge PR #8309: Fix Fixed Size marquee drag positioning, building on #6026 2026-10-22 15:37:34 +00:00
aiongg 8ac5ae11ec Merge PR #8245: fix(engine): Remove Background looks for the subject in the layer, not the canvas (#8235) 2026-10-22 15:37:33 +00:00
aiongg 408e2fedd9 Merge PR #8244: Object Selection: preserve Shift/Alt from mouse-down 2026-10-22 15:37:33 +00:00
aiongg a036d96302 Merge PR #8243: Pen: ⌥/Alt-drag grabs the last anchor's incoming handle too (#6077) 2026-10-22 15:37:32 +00:00
aiongg dfcdb011da Merge PR #8186: fix(paths): the Work Path is drawn only while selected or a path tool is active (#6551) 2026-10-22 15:37:32 +00:00
aiongg 26919b7a9a Merge PR #8152: fix(ui): grey out Select and Mask in the options bar without a selection (#7520) 2026-10-22 15:37:32 +00:00
aiongg b18d7f1749 Merge PR #7922: Create Rectangle and the other shape dialogs: right-click Width or Height to pick their unit (#7786) 2026-10-22 15:37:31 +00:00
aiongg 05b0ae05e4 Merge PR #6896: fix: detect dominant foregrounds with Object Finder
docs/gaps.md: header and change-log row once each (the PR's row on top).
docs/ui-parity.md: header and change-log row once each; the Selection row keeps main's #6549 sentence and adds the PR's #6548 sentence.
2026-10-22 15:37:31 +00:00
aiongg 7e3517c090 Merge PR #6835: Object Finder: hovering with an empty active layer shows the status-bar hint (#6549) 2026-10-22 15:37:30 +00:00
aiongg 704a420860 Merge PR #6692: Find small objects in large documents with the Object Finder 2026-10-22 15:37:30 +00:00
aiongg a1d23fd2a6 Merge PR #6484: Shapes: the Color panel, Swatches and Gradients fill the selected shape (#5568)
crates/engine/src/presets/tests.rs: tests appended by both sides, kept whole (the PR's before main's persistence sections).
crates/ui-egui/src/swatches_ui.rs: tests appended by both sides, kept whole.
2026-10-22 15:37:30 +00:00
aiongg afc3619b52 Merge PR #6252: Paths panel: rename a path in place, and name the Work Path when saving it
crates/ui-egui/src/vector_ui.rs: kept both sides in paths_panel (main's trash-drag state, the PR's rename id) and both new tests; the PR's tests call path_context_actions with main's added layer argument (None).
2026-10-22 15:37:29 +00:00
aiongg c9173cc9f2 Merge PR #6201: Add hex color input to shape Fill and Stroke popovers
docs/ui-parity.md: header keeps main's date and gains the PR's change entry; change-log rows for both, newest first.
2026-10-22 15:37:29 +00:00
aiongg 26dbd7953b Merge PR #5834: Shapes: rounded corners on live triangles, polygons and stars (TOOL-214-12)
docs/scorecard.md: ours (regenerated at combine).
docs/ui-parity.md: kept main's header and added the PR's change entry once.
2026-10-22 15:37:29 +00:00
aiongg f094216481 Merge PR #5412: fix(pen): an unclosed path stays with its document (#5373)
crates/ui-egui/src/lib.rs: kept both cancel_stale calls (perspective crop from main, pen from the PR).
crates/ui-egui/src/vector_ui.rs pen_down: kept main's resume-at-endpoint block and the PR's pen_doc claim; the claim comes first so a resumed path is claimed by its document too.
2026-10-22 15:37:29 +00:00
aiongg 7654482fd1 Merge PR #3330: Moving a shape keeps its rendering: Artboards to Files matches the reference app
crates/engine/src/layer_multi_cmds.rs: took the PR's call to shift_shape; main's added check that the moved path (plus stroke) stays inside the canvas moved into shift_shape (vector_cmds.rs) so both behaviours hold.
2026-10-22 15:37:29 +00:00
aiongg 1273077cda Merge PR #8296: fix(ui): Free Transform corners show the diagonal resize arrow on wide or tall boxes 2026-10-22 15:37:16 +00:00
aiongg e4d2c0cca6 Merge PR #8201: Warp: a surface drag commits exactly as previewed, now under test (#7885) 2026-10-22 15:37:15 +00:00
aiongg 900606460d Merge PR #8021: Keep previous crop length when normalizing rounds to zero 2026-10-22 15:37:14 +00:00
aiongg 3d7aa3e081 Merge PR #8010: fix(geom): reject extreme warp split indices without overflow 2026-10-22 15:37:14 +00:00
aiongg 3e95fd622b Merge PR #7943: fix(transform): keep artboard paper below child previews
docs/ui-parity.md: header change line: kept main's line and added this PR's item first; Last updated stays 2026-10-21.
2026-10-22 15:37:13 +00:00
aiongg 959a7c369f Merge PR #7798: fix(ui): guides draw over the Puppet Warp, Perspective Warp and Vanishing Point previews (#7781) 2026-10-22 15:37:13 +00:00
aiongg a78dfcf711 Integration: #6936 crop_size::target takes pt_per_in 2026-10-22 15:37:12 +00:00
aiongg 7c08e3d7bb Merge PR #6936: Crop tool: switching tools drops an untouched ratio-fitted frame instead of cropping
crates/algo/src/features.rs: comment-only conflict from a commit already on main; kept main's side.
crates/ui-egui/src/crop_ui.rs: fit_frame conflict; kept the PR's side (its 2-argument crop_size::target call does not compile on main; fixed in the next commit).
2026-10-22 15:37:12 +00:00
aiongg 48ccb21290 Merge PR #6786: Puppet warp: Fix discrepancy between preview and the final state
crates/ui-egui/src/puppet_ui.rs: tests added by both sides in the same place; kept both whole (#6754's pin-deletion tests, then #6786's drag-preview tests).
2026-10-22 15:37:12 +00:00
aiongg 6a257627b1 Merge PR #6754: Puppet warp: Delete and Backspace remove the selected pin 2026-10-22 15:37:12 +00:00
aiongg 9eb30f41a8 Merge PR #6224: Free Transform: a rotated sprite's soft edge keeps its colour instead of a light border 2026-10-22 15:37:11 +00:00
aiongg 3ce61355f1 Merge PR #6186: Free Transform: picking another tool or layer applies a background commit (#5665, #5667) 2026-10-22 15:37:11 +00:00
aiongg 63f3dbfd5b Merge PR #6042: fix(ui): keep Clone Stamp source preview crisp when zoomed in 2026-10-22 15:37:11 +00:00
aiongg 3c329fa530 Merge PR #5545: Keep Free Transform preview colors consistent with the canvas 2026-10-22 15:37:11 +00:00
aiongg 4ec2ef53c9 Merge PR #3926: feat(ui): Free Transform 3x3 reference point widget and projective homography pivot snapping 2026-10-22 15:37:11 +00:00
aiongg 8f09d122d3 Merge pull request #8562 from aiongg/fmt-main-fix
Format the quick export test (#8535)
2026-10-22 09:53:29 -04:00
aiongg 95e59a99c0 Format the quick export test (#8535) 2026-10-22 13:52:23 +00:00
Martin BallandClaude Opus 5.5 4bfeb7889d Fixes #8519 quick export honours extension (#8535)
* fix(engine): Quick Export writes the format its path names (#8519)

Quick Export as PNG renamed to photo.jpeg in the save dialog wrote PNG
bytes under a JPEG name, which upload checks refuse and file managers
don't preview. The path's extension (.jpg/.jpeg/.png/.gif/.webp) now picks
the format; any other extension is refused with a pointer to Export As;
no extension keeps Export Preferences' format.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* contributors: add benandez's real name

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 09:28:42 -04:00
Eddi Andree Salazar MatosandClaude Opus 5.5 686a4bb93e Merge storytold/photocraft main into fix/windows-reserved-names
Conflict in crates/engine/src/file_cmds/tests.rs: both sides added tests at
the end of the file. Kept all three (sanitize device names, exposure text,
gzipped XCF listing). main's Save for Web slice rework still goes through
`slice_file` / `portable_generated_stem`, so the device-name fix applies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 06:59:58 -05:00
zuujiscom 1104b6f211 ci(freebsd): run the photocraft-gpu tests one at a time
The nightly FreeBSD run fails because the timing test binary crashes with
SIGSEGV in the VM's software Vulkan driver when its tests set up devices in
parallel. Run with --test-threads=1 it passed 3 of 3 times on GitHub's x86_64
and aarch64 VMs and on a native FreeBSD 15.1 aarch64 VM, where all 121
photocraft-gpu tests pass. Exclude photocraft-gpu from the workspace test run
and test it serially, as ui-egui's integration tests already are.
2026-10-22 07:21:29 -04:00
Dylan M. Taylor c3c6c63074 security: encode printer names in IPP request paths 2026-10-22 07:12:25 -04:00
Dylan M. Taylor 5338f1de46 security: budget nested action playback and preserve batch auth 2026-10-22 07:12:21 -04:00
Dylan M. Taylor 81c694a3f9 security: bound RAW linked-directory metadata reads 2026-10-22 07:12:17 -04:00
Dylan M. Taylor 7c78c951e4 security(io): index comp IDs in PSD layer-comp import
decode_comps matched each nonzero layer-setting comp ID against the comp
vector with iter_mut().find(), an O(n*m) scan over two file-controlled
collections. A crafted PSD could cause quadratic CPU work during import.

Build a comp-ID-to-first-index map once and use it for setting lookups.
or_insert preserves first-match behavior for duplicate comp IDs; comp 0
(Last Document State) is still handled separately.

Finding: 47567ac0-a75f-4557-b127-ffc855e5bd8d (CWE-407)
2026-10-22 07:12:12 -04:00
Dylan M. Taylor e75790b878 security(io): index XCF parasite names for duplicate replacement
parasites() implemented last-duplicate-wins by scanning all previously
collected names with iter_mut().find() per record. Parasite records are
file-controlled, so many distinct names caused O(n^2) CPU work before
unrecognized metadata was discarded.

Maintain a name-to-index map so replacement is O(1) while preserving
insertion order and last-value-wins behavior.

Finding: 6107f41e-9b17-4632-a9df-f88a74a796a8 (CWE-407)
2026-10-22 07:12:06 -04:00
Dylan M. Taylor f42d7ef41a security(codecs): index FITS header keywords for O(1) lookup
Header::get scanned every card linearly, and axis resolution looks up
NAXIS1..NAXISn while find_image/data_len re-resolve the axes. Both the
commentary-card count and the NAXIS count are file-controlled, so a
crafted header multiplied them into quadratic header-processing work
without a correspondingly large decoded image.

Build a first-occurrence keyword index once in parse_header (skipping
HIERARCH cards, matching the prior filter) and use it in get().

Finding: eb2121d8-9dee-4422-bd57-f78dce60a61b (CWE-407)
2026-10-22 07:11:58 -04:00
Dylan M. Taylor dc6057d3f2 security(engine): deduplicate ATN action names in near-linear time
actions.load gave each imported action a unique name with a helper that
restarted suffix numbering at 2 and linearly scanned all accepted names
for every candidate. A file repeating one base name many times caused
cubic comparison work during load.

Replace it with a UniqueNames helper backed by a HashSet for membership
and a per-base-name next-suffix cache, preserving the name, name 2,
name 3 ... sequence and first-match behavior.

Finding: 9c7a5fdb-131e-4687-8175-58d72007b0c5 (CWE-407)
2026-10-22 07:11:54 -04:00
Dylan M. Taylor 3495a4b225 security(codecs): dedup Cryptomatte keys and cache stream lists
Cryptomatte metadata enumeration had two file-driven amplifiers:
- cryptomatte_keys() tested key membership with iter().any(), O(n^2) in
  the number of distinct cryptomatte/<key>/<field> attribute groups;
- layers() called layer_streams() for every key, rescanning all channels
  and allocating a fresh owned stream list even when many keys name the
  same layer.

Use a HashSet for key membership (keeping file order) and cache
layer_streams results by resolved layer name within each header, so
repeated keys reuse the discovered streams.

Findings:
- 120f4d51-b981-4fcc-abec-26f45f3efd29 (CWE-407, quadratic key membership)
- dae6e76b-a491-4089-892f-4cf34b911f36 (CWE-400, repeated per-key stream scans)
2026-10-22 07:11:50 -04:00
Dylan M. Taylor 19e8c0d7cd security(codecs): stop quadratic rescans in XMP value scanning
xmp_values advanced its cursor only past the matched key, so the
value-closing search (find(close)) rescanned the remaining packet for
every property-like substring. Repeating text such as
' tiff:ResolutionUnit>' inside the packet produced quadratic CPU work
even when no real resolution property existed.

Advance the cursor past each located value; a missing close delimiter
means the value is unterminated, so no later value can parse and the
scan stops. Valid multi-value extraction is unchanged.

Finding: 6ce55e33-27f0-4fb9-a22f-737382e1d328 (CWE-407)
2026-10-22 07:11:45 -04:00
Dylan M. Taylor ac7eca536a security(engine): convert spelling offsets in one pass
The spelling list converted each misspelling's start and end byte offset
to a character offset with char_at, which recounts the text prefix from
the start every call. A layer with many short misspellings therefore
caused quadratic rescanning on the synchronous UI thread.

Collect the ascending offsets and map them to character offsets in a
single forward walk over the text. char_at is removed (byte_at still
handles the reverse direction in change()).

Finding: 392eb136-b836-4400-8ed7-f1e4549ff0ae (CWE-407)
2026-10-22 07:11:41 -04:00
Dylan M. Taylor 2c02d06392 security(engine): count GIF frame delays with a map
fps_from_delays counted each frame's delay by scanning a growing vector
with iter_mut().find(), O(n^2) for a GIF with many distinct delays. A
crafted animation within the frame limit could stall the synchronous
import path.

Count delays in a HashMap and keep a first-seen order vector so the
most-common delay still wins, breaking ties toward the earliest-seen
delay exactly as before.

Finding: 23d54421-8e26-41b9-9690-ae1125ee2f85 (CWE-407)
2026-10-22 07:11:37 -04:00
Dylan M. Taylor ef6092b125 security(xtask): validate corpus paths and bound symlink traversal
Row 16 (9f6a3c79): preserve platform filename semantics in corpus scans
(no backslash-to-separator rewrite on Unix) and enforce destination
containment before copying files into the corpus directory.

Row 20 (6e2a985b): skip directory symlinks, dedupe visited directories by
canonical identity, and cap total directories/files so symlink cycles can
no longer drive exponential traversal.

Fixes findings 9f6a3c79-8bdf-4751-8eb4-87d3154074c9 and
6e2a985b-6b14-4b97-af07-7ca8333b9b75.
2026-10-22 07:11:30 -04:00
Dylan M. Taylor 5adebc5d6d security(affinity): index object fields for O(1) lookup
Object::get scanned the field vector linearly. An Affinity file can
define one large shared object and reference it repeatedly as sibling
layers, so repeated property lookups multiplied into quadratic CPU work
without the fields appearing more than once in the file.

Build a first-occurrence tag index when an object's fields are set
(via Object::new and set_fields, the latter covering the root object
that is filled after it is pushed). Positional fields (tag 0) stay out
of the index, and first-match semantics are preserved.

Finding: c315c4b0-dceb-459e-b39a-fa9c68ae6f40 (CWE-407)
2026-10-22 07:11:24 -04:00
Dylan M. Taylor 29e555c2f6 security(affinity): index live-corner radii by record
subpath() looked up each polyline anchor's corner radius by scanning the
entire radius list, O(anchors * radii). A file supplying large anchor and
radius arrays could cause quadratic CPU work before geometry limits
inspected the path.

Build a record-to-maximum-radius map once and look each anchor up in it,
preserving the previous fold(0.0, f64::max) behavior for duplicate record
indices.

Finding: 89f65d65-f637-44fc-9a81-a9039a413a4b (CWE-407)
2026-10-22 07:11:20 -04:00
Dylan M. Taylor 7c07280b66 security(affinity): bound raw child-slot traversal work
Reader::children re-collected a group's child objects by scanning every
raw Chld array slot, discarding nulls before the node budget counted
anything. A shared group holding a large null-filled child array, linked
many times, multiplied into excessive CPU work the MAX_NODES limit never
observed.

Charge every raw child slot (nulls included) against a document-wide
MAX_CHILD_SLOTS budget before filtering, via a new Stream::array_len
helper, and return a limit error when it is exhausted.

Finding: 3973fcd0-acda-4312-b538-d986dcffeb1c (CWE-400)
2026-10-22 07:11:15 -04:00
Dylan M. Taylor d0ad7ddb6f security(psd): check padding length before scanning it
FilterEffects::parse tested the trailing bytes with
rest.iter().all(|b| b == 0) before checking rest.len() <= 3, so every
loop iteration scanned the whole remaining buffer. A block with many
empty sections produced quadratic CPU work.

Reorder the short-circuiting && so the three-byte length check gates the
zero scan, making each padding check constant work. Behavior is
otherwise identical.

Finding: 4887ae49-cfda-43b0-9296-a1816fc1ddba (CWE-407)
2026-10-22 07:11:10 -04:00
Dylan M. Taylor 58cd22d4ff security(doc): compute auto-slice coverage per band, not per cell
auto_slices tested every grid cell against the full slice-rectangle list
(covered(x, y) = taken.iter().any(...)). With slice edges forming the
grid, that is cubic in the slice count, so a crafted document's slice
arrangement could stall ordinary canvas drawing on the UI thread.

For each row band, mark covered x-cells in a single pass over the rects
that span the band, then scan the precomputed cells. This drops the inner
work from O(rects) per cell to O(1), while producing the same tiling.

Finding: 9fbea43b-3096-4cdc-b005-86d44a8fe57e (CWE-400)
2026-10-22 07:11:06 -04:00
Dylan M. Taylor 9e8a3eecf0 security(cms): bound inverse-curve and CLUT interpolation work
Two per-pixel/per-sample amplifiers driven by a crafted ICC profile:

- table_inverse recomputed a sampled curve's flat-run edge indices by
  scanning the whole table on every call, and transform construction
  inverts each destination curve OUT_TABLE times. Compute the edges once
  per curve via a new Curve::inverse_table batch method.
- eval_multilinear enumerated 2^inputs cube corners over every declared
  input, including single-grid-point (degenerate) dimensions that
  contribute nothing. Interpolate over the effective dimensions only, so
  a 15-channel CLUT of one-point dims no longer does 2^15 iterations per
  pixel.

Findings:
- acfbe3a3-ee58-4b7e-b72a-5a4877dd15c9 (CWE-407, repeated flat-run scans)
- 9fcc72e7-46d6-4f75-9333-cb14a65eeafd (CWE-407, degenerate-dim corner blowup)
2026-10-22 07:11:02 -04:00
Dylan M. Taylor fed04ecef3 security(codecs): budget Cryptomatte per-pixel pair capacity
The decode admission counted each pixel's channel samples and vector
header but not the heap capacity of the per-pixel (id, coverage) pair
lists. A compressed Cryptomatte document could therefore return pixel
storage exceeding the caller's max_alloc budget.

Include the maximum pair capacity (one pair per rank, 8 bytes each) in
the per-pixel byte count before check_bytes.

Finding: 5482ed4a-bc95-43fc-84d1-d5bfb6373761 (CWE-400)
2026-10-22 07:10:58 -04:00
Dylan M. Taylor 867caefb5c security(codecs): admit deep-EXR output size and validate chunks first
Two deep-EXR allocation gaps:

- flatten() allocated the flattened output and the full display-window
  stage (nc f32 samples per pixel) without a limit check; the deep decode
  only admitted the data window at four bytes per pixel, before the
  channel layout was known. Pass Limits into flatten and re-check both
  buffers at the real f32 layout size.
- decode_deep built a per-pixel usize block index before validating the
  chunk's declared packed sizes. Compute the block bounds first, validate
  the payload is present, then materialize the index, so a truncated or
  lying chunk is rejected before the index is allocated.

Findings:
- feb6fcd8-58df-47e2-a04a-d4d81d5565aa (CWE-789, display-stage allocation)
- 9c53025d-65f8-4bcc-9161-143a4c5d7f46 (CWE-789, index before payload check)
2026-10-22 07:10:54 -04:00
Dylan M. Taylor 9edcb97687 security(codecs): admit HEIF coded image size, not just transformed
When transforms (orientation/clean-aperture crop) are applied, admission
checked only the transformed output size. The delegated HEVC decoder
allocates the full coded image before cropping, so a HEIF with a small
visible aperture over a large coded image bypassed the limit policy.

Always check the coded dimensions against the full limits before
delegating, in addition to the transformed size.

Note: the related SPS-driven and grid-tile allocations (findings
75048229 and 2db4c59e) occur inside the pinned heic-rs dependency from
parameter sets the container does not expose; fully bounding those needs
a decoder-side change and is tracked separately.

Finding: ec5cec31-8a62-4963-8045-e3ec9d9e4931 (CWE-400)
2026-10-22 07:10:49 -04:00
Dylan M. Taylor 1f2be70057 security(raw): bound TIFF structure-dump output
Tiff::dump appended each entry's full ASCII value with no per-value or
total-output limit. Thousands of entries can reference one large aliased
ASCII value, expanding a small diagnostic input into a multi-gigabyte
String.

Cap each ASCII preview to 256 bytes (truncated at a char boundary) and
stop the listing once the accumulated output reaches 1 MiB, marking it
truncated.

Finding: d994ec7f-25dd-4ae7-9673-04adc0aa8a3c (CWE-400)
2026-10-22 07:10:44 -04:00
Dylan M. Taylor c5a6afcf68 security(raw): reject tiled Sony cRAW and cap row references
read_craw treats every segment as a run of full-width image rows and
grew the row-reference vector before checking coverage. A tiled layout
(which does not describe full-width rows) with repeated tile offsets
could push far more than height rows, allocating gigabytes before the
post-loop coverage check ran.

Reject tiled cRAW layouts up front (cRAW is only stored as strips) and
stop collecting rows once the image height is reached.

Finding: 3c2601a1-ad92-45b6-96f7-be5da87d97eb (CWE-400)
2026-10-22 07:10:40 -04:00
Dylan M. Taylor 163a0000a5 security(raw): position RW2 bit cursor in constant time
compressed_row positioned the bit cursor by replaying every preceding
byte within its page (skip % PAGE calls of get(8)). For narrow images
this replay runs per row, so a small file could consume disproportionate
CPU just setting up cursors.

Add Bits::new_at, which sets the page position and remaining-bit count
directly from the row offset in O(1), and use it in compressed_row. A
unit test proves it matches the old byte-by-byte replay.

Finding: 2a7d3d4b-90ab-4887-9dba-c3884e224990 (CWE-407)
2026-10-22 07:10:36 -04:00
Dylan M. Taylor 53bee4aa81 security(raw): interpolate gain maps per pixel, not per full row
GainMap::row materialized the entire horizontal gain grid (points_h
floats) once per applicable image row, even though each output pixel only
needs two neighboring points. File-controlled image height and horizontal
map size multiplied into processing and allocation churn far beyond the
input size.

Add GainMap::at_rv, a direct four-corner bilinear lookup, and use it per
output column in gain_row so no points_h-wide row is built per image row.
A test proves at_rv matches the former row() + at() path.

Finding: 7ebec8df-1a7a-4d33-915f-097f8e9be0e5 (CWE-400)
2026-10-22 07:10:32 -04:00
Dylan M. Taylor bf98617e20 security(io): apply caller limits to RAW preview decoding
upright_preview decoded a raw file's embedded JPEG with default codec
options, discarding the caller's DevelopOptions.limits. A compressed
embedded preview could therefore allocate and process far more pixel
data than the caller authorized during the fallback and color-fit paths.

Pass the caller's limits into upright_preview and translate them into
the JPEG DecodeOptions, so both the Unsupported fallback and the
preview look-fit respect the same policy.

Finding: d17bc286-6765-41ce-bd41-579ee5b75cfa (CWE-409)
2026-10-22 07:10:28 -04:00
Dylan M. Taylor 2e2faec1a3 security(raw): cap lossless-JPEG Huffman table builds
Each DHT definition for table id 0..3 calls Huffman::new, which
allocates and fills a ~48 KiB lookup pair. A stream repeating DHT
definitions (and DNG tiles referencing such a stream) could rebuild
tables thousands of times, multiplying header-processing cost and
memory churn while producing little output.

A real lossless JPEG uses a handful of Huffman tables; cap cumulative
table builds per stream at 64 and reject streams that exceed it.

Finding: 7a31b581-d1b7-4f9c-91e5-ce42b47db87f (CWE-400)
2026-10-22 07:10:24 -04:00
Dylan M. Taylor 8c0a9cb185 security(raw): bound cumulative retained tile memory
read_plane decodes every tile in parallel and retains all decoded tile
buffers in  before assembly copies only the useful pixels. Each
tile is charged individually, but file-controlled padding and repeated
source ranges can make the cumulative retained samples far exceed both
the input and the useful image.

Sum the padded tile samples (two bytes each) and reject the plane before
decoding when that total exceeds max_alloc.

Finding: e66981b1-0d7a-4738-a64c-37b5e8bf28d1 (CWE-770)
2026-10-22 07:10:19 -04:00
Dylan M. Taylor 0e1a7a8b2a security(io): bound PDN layer-name copies
PDN conversion copied a referenced name into an owned String for every
output layer, while the NRBF budget charged the shared string only once.
Repeated references to one large name could multiply a small document
into gigabytes of retained metadata.

Cap each layer name at 64 KiB and the aggregate copied name bytes at
4 MiB, checked before each copy.

Finding: fd77f4b2-39a2-48b5-b9d8-4ad69cf69469 (CWE-770)
2026-10-22 07:10:15 -04:00
Dylan M. Taylor bd109515a9 security(io): bound XCF copied layer metadata
The XCF importer reparses every layer offset and allocates a new owned
name and item path per reference. Repeated offsets referencing one
record with a large name or path could retain gigabytes of metadata,
which the per-field and per-layer limits did not bound in aggregate.

Add a document-wide metadata budget (32 MiB) to the Image reader and
charge each copied layer name and item path against it, erroring when
exhausted.

Finding: 1631d2df-fe8f-4734-b14e-380db11afb26 (CWE-789)
2026-10-22 07:10:12 -04:00
Dylan M. Taylor 7016152f22 security(compose): bound dense mask working set
build_mask derives its working area from sparse mask geometry grown by
feather, so two distant changed pixels could span a canvas-sized
rectangle. It then allocated f32 planes (coverage, pixel-mask values,
blur scratch) over that area, gigabytes during even a one-pixel render.

Check the estimated working bytes before the dense allocation and fall
back to the constant default mask above 512 MiB, so pathological
geometry degrades feather detail instead of exhausting memory.

Finding: 8c04c5ef-a5bd-48f4-bfcf-83615bcf11c0 (CWE-770)
2026-10-22 07:10:08 -04:00
Dylan M. Taylor f65832931b security(compose): cap uncached pattern conversion
When a pattern exceeded the preparation cache budget, every lookup still
called Tile::new_in, expanding the whole sparse pattern into RGBA floats
on each output tile (and possibly concurrently). The cache budget only
controlled retained entries, not this conversion work.

Reject patterns whose full RGBA conversion would exceed an absolute
256 MiB cap on the uncached path, so oversized patterns are skipped
instead of re-expanded per tile. The cap is independent of the cache
budget, so small patterns still convert on demand when nothing is cached.

Finding: cc0b2f77-5578-48ed-aba0-1f3beccd21cd (CWE-400)
2026-10-22 07:10:03 -04:00
BFlat 2438d38084 Merge pull request #8514 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:22 +07:00
bflatastic d2e349ee6c README: the app icon above the title 2026-10-22 17:58:16 +07:00
FartmanAndBurps f545b39e98 docs(print): explain dialog settings and submission lifecycle 2026-10-22 09:37:40 +01:00
PerchAnton 8df3ffd307 fix(engine): simplify displacement map predicate for strict Clippy 2026-10-22 10:45:59 +03:00
PerchAnton b165afc332 Merge main into #7721 and retain exact Crystallize and Ripple fields 2026-10-22 10:12:20 +03:00
Brian TorresandClaude Sonnet 5.5 68245f1cb4 Raw: list the Nikon ColorSpace tag in the clean-room sources and bump the gaps status line (#5987)
The README's clean-room section names every maker-note tag the crate reads and
where it is documented; 0x001E is added, and the code comment now names
ExifTool's Nikon tag documentation as its source.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-21 21:57:54 -07:00
Brian TorresandClaude Sonnet 5.5 f530a98406 Raw: a Nikon maker note's ColorSpace sets the preview colour space (#5987)
embedded_preview_color_space only read the EXIF ColorSpace, but Nikon bodies
write none: they say Adobe RGB only in the maker note (tag 0x001E: 1 sRGB,
2 Adobe RGB). A Nikon NEF with Adobe RGB selected had its JPEG preview read as
sRGB by the colour fit.

When the EXIF does not state a colour space, the Nikon maker note's is used
(the EXIF still wins, a DNG still states nothing, other makers' maker notes
are not read). The maker-note locator in tiffep.rs is shared. A packed Nikon
NEF with the tag joins the hostile-input seeds, and rawinfo prints the stated
colour space.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-21 21:57:42 -07:00
Brian TorresandClaude Sonnet 5.5 140fe877ce Docs: record the D4 white-level fix in the status lines and file-format revision history (#5978)
AGENTS.md asks for the status line and revision history of the parity docs to move with the change: gaps.md and file-format-parity.md now carry it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-21 21:57:32 -07:00
Brian TorresandClaude Sonnet 5.5 db01269fea Raw: the Nikon D4 white level follows the sample depth (#5978)
The D4 profile's saturation level of 15520 was measured on 14-bit files but
applied to the 12-bit files the body also writes, whose samples are the
14-bit values shifted right by two. 12-bit D4 NEFs developed about 2.6 stops
darker than the same scene at 14 bits, with no warning.

The profile now records the depth its level was measured at, and
cameras::apply takes the stored sample depth: 14-bit files keep 15520, 12-bit
files get 3880. A synthetic D4-sized NEF at both depths must develop within
0.2 stop (it was 2.5 stops apart before); on the public 12-bit D4 files the
difference to the 14-bit twin is now within 0.02 stop.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-21 21:57:11 -07:00
aiongg 78b46b1a99 Merge pull request #3283 from abc2cb/codex/github-smart-filters-usability-20261011
Smart Filters: distinct editors, shared disclosure and stable alpha
2026-10-22 00:26:00 -04:00
Ian Swope b0ed288292 Compose: the effect cache estimate counts the maps the builder makes (strokes' distance fields, none for overlays)
fx_region_bytes counted the shape plus one map per enabled effect. Strokes
also build an inside distance and one or two outside fields, and overlays
and strokes build no map of their own, so a stroke-heavy document could
stay under the estimate while thrashing, and an overlay-only one could be
clipped though it fits. effects::map_count, next to build_maps_prepared,
counts what it builds; the test checks it equals FxMaps::bytes per layer.
2026-10-21 21:58:39 -05:00
aiongg 1033aab750 Integration: cargo fmt 2026-10-22 02:56:39 +00:00
aiongg 62d4573609 Integration: cargo fmt 2026-10-22 02:41:49 +00:00
aiongg 995927592b Integration: DDS_PITCH is defined once after #5864 met the DDS writer 2026-10-22 02:36:51 +00:00
aiongg 882cbe1c2c Merge PR #7240: Save As on Linux: the one file type offered gives the name its extension 2026-10-22 02:36:51 +00:00
Khawar Zamman WaniandClaude Opus 5.5 3649a4e6ea docs(ui-parity): a Hand-tool ⌘-drag follows Scrubby Zoom (#6434)
The Hand tool's held Zoom goes through `zoom_tool::drag`, so with Scrubby
Zoom off a ⌘-drag draws a zoom rectangle rather than scrubbing. Say so in
the parity row, and test that case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:06:05 +05:30
aiongg 98d8ada8b9 Integration: cargo fmt 2026-10-22 02:33:11 +00:00
aiongg 624b7ebaf7 Integration: #7262 Clipping Mask export refuses a numeric layer reference, as the other layer events do 2026-10-22 02:32:30 +00:00
aiongg 1e0532c7ff Integration: cargo fmt 2026-10-22 02:31:11 +00:00
aiongg d3cc3a69fb Integration: #8344 compare the Photo Filter dictionary as bytes (the field is Vec<u8> on main) 2026-10-22 02:30:49 +00:00
aiongg de2d5255fc Integration: #5566 the panel-defaults test sets the edit's document (TextEdit gained the field on main) 2026-10-22 02:30:33 +00:00
aiongg 754692c45c Integration: #7638 test's Bevel literal sets gloss_anti_alias, a field main added 2026-10-22 02:28:48 +00:00
aiongg 81fb7e7277 Integration: #6453 the T-icon drag test sets the edit's document 2026-10-22 02:28:23 +00:00
aiongg 82df3f7fe6 Merge PR #8034: fix(format): make Windows native-save publication durable
crates/format/src/store.rs: the Windows tiles/blobs directory flush goes before main's manifest write (main publishes the manifest before removing stale previews), and that write takes the PR's fs argument; the PR's second manifest write is dropped.
2026-10-22 02:28:02 +00:00
aiongg dfde38919e Merge PR #7883: Save As JPEG: JPEG Options dialog (quality 0–12, Baseline / Optimized / Progressive) (FILE-215-19)
crates/ui-egui/src/lib.rs: replay_save doc comment names both Export As and JPEG; replay_save uses the PR's FormatChoices::from_step, after main's export_dialog::replay branch.
docs/file-format-parity.md: header keeps main's text plus the JPEG Options item (last updated 2026-10-19); TIFF row is main's, JPEG row is the PR's; change-log rows once each, newest first.
docs/gaps.md: header keeps main's text plus the G10 item (last updated 2026-10-19).
2026-10-22 02:27:46 +00:00
aiongg 7a25b3f38b Merge PR #7099: test(cli): pin photocraft-cli argument parsing before the clap port (#7071) 2026-10-22 02:27:40 +00:00
aiongg 0ad6cfccd7 Merge PR #7795: Print: Scale to Fit Media fills the driver's printable area (#7782)
Conflicts:
crates/engine/src/print_cmds/tests.rs: appended tests, kept both
2026-10-22 02:27:40 +00:00
aiongg 97ba6b3aa9 Merge PR #5980: Raw: the Nikon D4 white level follows the sample depth (#5978)
Conflicts:
docs/gaps.md: header line is ours plus the PR's missing change item (later Last updated date); change-log rows kept both, newest first
docs/file-format-parity.md: same
2026-10-22 02:27:26 +00:00
aiongg 57028352b4 Merge PR #7662: Preferences: Zoom Resizes Windows resizes floating document windows, and the Zoom tool shows it as Resize Windows to Fit (#204)
crates/engine/src/prefs.rs: neither general.zoomResizesWindows (this PR implements it) nor general.animatedZoom (PR #3698 in this track implements it) stays in HIDDEN_UNTIL_IMPLEMENTED.
crates/ui-egui/src/prefs_ui.rs: kept both is_hidden asserts.
crates/ui-egui/src/i18n/*.tsv (20 catalogs): union of both sides' rows; no duplicate (context, source) keys.
docs/scorecard.md: ours (regenerated at combine).
2026-10-22 02:27:20 +00:00
aiongg 39fbee3dab Merge PR #5988: Raw: a Nikon maker note's ColorSpace sets the preview colour space (#5987)
Conflicts:
docs/gaps.md: header line is ours plus the PR's missing change item (later Last updated date)
2026-10-22 02:27:12 +00:00
aiongg d8865fc682 Merge PR #7638: Map the bevel's shading through its gloss contour, not its light (#7360)
crates/compose/src/effects.rs: kept main's par_units row loop in bevel_maps and ported the PR's change into it: k maps shade and the flat-face level through the gloss contour (lut_at), and the closure returns (hi, sh) without the apply_contour pair.
2026-10-22 02:27:06 +00:00
aiongg 79ff67c3f2 Merge PR #7249: fix(atn): reject non-replace select.rect modes in .atn export (#7194)
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended tests, kept both
2026-10-22 02:27:06 +00:00
aiongg ed92bfdedb Merge PR #6452: Hand tool: hold Ctrl/Cmd to zoom in, Alt to zoom out (#6434)
Conflicts:
docs/ui-parity.md: header line is ours plus the PR's missing change item (later Last updated date); change-log rows kept both, newest first
2026-10-22 02:27:01 +00:00
aiongg e42cfd2159 Merge PR #7574: Move tool: moving selected pixels shrinks the selection, transform controls and snapping to them (#7538)
crates/engine/src/float_cmds.rs: kept main's cut_piece (parallel, shares full tiles) and try_lift_coverage, and the PR's CutParts::outline; the outline is read from the piece's alpha, which holds min(alpha, selection) as the PR's own lift loop computed, instead of replacing cut_piece with that loop.
crates/engine/src/float_cmds/tests.rs: git interleaved the two sides' new tests; took main's file and appended the PR's added tests whole.
docs/ui-parity.md: header line keeps main's text plus the PR's #7538 item.
2026-10-22 02:26:55 +00:00
aiongg ac0e29ca4c Merge PR #7115: fix(atn): map Apply Layer Comp steps by name (#7101)
Conflicts:
docs/gaps.md: header line is ours plus the PR's missing change item (later Last updated date); change-log rows kept both, newest first
docs/file-format-parity.md: same
2026-10-22 02:26:51 +00:00
aiongg d1f5bae8c3 Merge PR #7232: fix(atn): export recorded layer.select with name parameter (#7185)
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended tests, kept both
2026-10-22 02:26:31 +00:00
aiongg 8a4fe7c607 Merge PR #7656: Refuse .atn export of resolution-only Image Size without keepPrintSize
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended tests, kept both
2026-10-22 02:26:26 +00:00
aiongg 1fbf1d8f14 Merge PR #7800: Guides: show the X or Y position beside the pointer while dragging
Conflicts:
docs/ui-parity.md: header line is ours plus the PR's missing change item (later Last updated date); change-log rows kept both, newest first
2026-10-22 02:26:17 +00:00
aiongg d8cf34a6bc Merge PR #8203: Preferences: list the languages alphabetically by their own name 2026-10-22 02:26:11 +00:00
aiongg 4fed3ddc57 Merge PR #8137: Fix tooltip text in scrub_label function 2026-10-22 02:26:09 +00:00
aiongg 02c9e32d1d Merge PR #8088: fix(type): resolve exact PostScript names in type.create 2026-10-22 02:26:06 +00:00
aiongg c68d43a115 Merge PR #7947: Type: hovering a served font fetches it after a short rest, not as the pointer passes 2026-10-22 02:26:04 +00:00
aiongg 516c773827 Merge PR #7511: i18n(cs): bring the Czech catalog up to date 2026-10-22 02:26:01 +00:00
aiongg 4eef797fdf Merge PR #7968: fix(atn): a Group Layers step keeps its explicit layer through .atn (#7236)
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended test helper and tests (two blocks), kept both
2026-10-22 02:25:59 +00:00
aiongg 607b4f5268 Merge PR #7273: Fix: add missing Chinese translations for "Up" and "To" 2026-10-22 02:25:58 +00:00
aiongg 5fd5b7adcb Merge PR #7152: Type: register craft fonts of every script, not only the Japanese ones 2026-10-22 02:25:56 +00:00
aiongg d38019873a Merge PR #7142: perf(type): refresh type layers without cloning the document (#7092) 2026-10-22 02:25:53 +00:00
aiongg d2656c2f26 Integration: #6974 Hindi native review column in docs/localization-parity.md is 'no' 2026-10-22 02:25:53 +00:00
aiongg ca19279232 Merge PR #5731: i18n(tr): comprehensive review, terminology refinement, and industry-standard UI consistency overhaul
Conflicts:
crates/ui-egui/src/i18n/tr.tsv: rows kept both, the duplicate Use Global Light row dropped keeping the PR's translation; no duplicate keys
2026-10-22 02:25:47 +00:00
aiongg 2d66820811 Merge PR #6974: feat(i18n): add Hindi (hi) language catalog and locale resolution
contributors/people.toml: union of both sides' entries.
2026-10-22 02:25:47 +00:00
aiongg 8a1425fd30 Merge PR #7421: Camera Raw: the full-resolution preview of a raw open shows the raw-stage sliders 2026-10-22 02:25:46 +00:00
aiongg a77b078589 Merge PR #7417: Camera Raw: choose the demosaic method when a raw opens
crates/io/src/lib.rs: kept main's import_psd (and its call in import_stages); import_stages takes the PR's ImportOptions argument.
crates/io/src/raw.rs: the PR's demosaic summary on main's developed_document(name, bytes, ...) call.
docs/scorecard.md: ours (regenerated at combine).
2026-10-22 02:25:41 +00:00
aiongg fadc536dbb Merge PR #6793: Type: paragraph direction buttons in the Paragraph panel and options bar
crates/ui-egui/src/i18n/*.tsv (20 catalogs): both sides appended rows at the same place; union, no duplicate keys.
crates/ui-egui/src/state.rs: both new ToolOptions fields (type_style_defaults, type_direction) and defaults.
crates/ui-egui/src/tool_reset.rs: Type tools reset both type_direction and type_style_defaults.
crates/ui-egui/src/type_tool.rs: new type is created from default_style_params (main); direction added to it, and apply_defaults stores a direction edit in type_direction.
docs/gaps.md: header and G8 evidence from main, G8 missing text reworded for direction buttons, change-log lines of both.
docs/scorecard.md: ours (regenerated at combine).
2026-10-22 02:25:37 +00:00
aiongg c2b459490e Merge PR #5529: Value fields: the mouse wheel steps them, as in the reference app (#5473) 2026-10-22 02:25:35 +00:00
aiongg 49a09628b6 Merge PR #7637: Record one action call when playback pauses for a step dialog
Conflicts:
crates/ui-egui/src/i18n/*.tsv (20 catalogs): rows kept both; no duplicate (context, source) keys
2026-10-22 02:25:34 +00:00
aiongg 0f8ca48023 Merge PR #6453: Type options: drag the "T" icon to change the font size, as in the reference app (#6437)
docs/ui-parity.md: header line keeps ours (date 2026-10-21) plus the PR's change item; change-log rows kept both, newest first.
2026-10-22 02:25:27 +00:00
aiongg 624c9b743e Merge PR #7262: fix(atn): reject numeric layer references in .atn export (#7248)
crates/engine/src/jobs.rs: kept both Running fields (recorded_by, pre_selected) and both initialisers; main's new_fx_target block and foreign-recording flush kept; after_command takes the PR's pre_selected argument at both call sites.
2026-10-22 02:25:23 +00:00
aiongg 48f391e912 Merge PR #6897: GPU fallback: Retry GPU after an error rebuilds the GPU canvas now, no restart (#6577)
crates/ui-egui/src/lib.rs: union in the PhotocraftApp fields, the initialiser and set_wgpu (main's window_device/window_lost/window_recovery_retry and set_available, the PR's gpu_render_state/gpu_fault).
docs/control-protocol.md: ui.gpu.simulateLoss line takes the PR's text and keeps main's destroy: true sentences.
2026-10-22 02:25:08 +00:00
aiongg 77ad0f2d0e Merge PR #7605: Lasso tools: the outline is solid until the selection is made (#7495)
crates/ui-egui/src/canvas.rs: test modules kept both (blend_viewport_regression and lasso_preview_tests); the non-test freehand Lasso code merged without conflict.
2026-10-22 02:25:04 +00:00
aiongg 28a1ee7b6e Merge PR #6468: fix(photocraft): --control 0 logs the port actually bound (#6427) 2026-10-22 02:24:53 +00:00
aiongg b7e01f8bda Merge PR #7591: fix(atn): refuse strict export of canvas-bound selection modifies and placed pattern buckets
crates/engine/src/atn_cmds.rs: no `uncarried` macro arm; the four Select > Modify events (Feather, Expand, Contract, Smooth) use the existing `unless` arm with a new predicate `applies_at_canvas_bounds` (wraps the PR's `flag_set`). Helper fns kept both (`preserves_roundness`, `flag_set`).
crates/engine/src/atn_cmds/tests.rs: both sides' appended tests kept whole.
2026-10-22 02:24:51 +00:00
aiongg e3fb147154 Integration: #6283 drop the 7 ru.tsv rows already on main (Command, Compression, Contract, Cross, Diamond, Duplicate Layer, Lock Layers); Decrease/Increase Brush Size are single rows after the merge 2026-10-22 02:24:49 +00:00
aiongg fd88c57691 Merge PR #5443: Actions panel menu: Load Actions and the rest of the commands (#5021) 2026-10-22 02:24:48 +00:00
aiongg f25f274aa3 Merge PR #6283: i18n(ru): translate the dynamic labels the Russian catalog was missing
crates/ui-egui/src/i18n/ru.tsv: both sides added rows at two places; kept both (union).
docs/gaps.md: header from main, change-log lines of both.
docs/localization-parity.md: change-log lines of both.
2026-10-22 02:24:48 +00:00
aiongg 2c2269ff0b Merge PR #5421: fix(format): serialise directory bundle saves with a bundle-wide lock
crates/format/src/store.rs: kept both field sets in PcraftWriter::default (main's blob_hash_cache/compressed_budget and the PR's lock_wait) and both setters (set_compressed_cache_budget, set_lock_wait).
2026-10-22 02:24:47 +00:00
aiongg 7da9c62e4a Integration: #8190 test that the theme swatch star badge on hover does not move the next swatch 2026-10-22 02:24:44 +00:00
aiongg 3a72e25b02 Merge PR #3921: feat(layer-style): expose Bevel & Emboss highlight and shadow shading
crates/engine/src/layer_style.rs: union of main's glossAntiAlias and the PR's six shading params in the bevel defaults and overlay_effect; both sides' tests kept.
crates/ui-egui/src/layer_style.rs: union: P::Contour(Some("glossAntiAlias")) plus the six new rows and values_of lines.
crates/ui-egui/src/i18n/*.tsv (16 catalogs): union of both sides' rows; no duplicate (context, source) keys.
2026-10-22 02:24:33 +00:00
aiongg 889cba17b2 Merge PR #7150: i18n(pl): translate the 14 counted status messages missing from Polish
crates/ui-egui/src/i18n/mod.rs (tests module): both sides' appended tests kept whole.
2026-10-22 02:24:25 +00:00
aiongg 7a01dee9f4 Merge PR #7159: i18n(id): translate the 14 counted status messages missing from Indonesian
crates/ui-egui/src/i18n/mod.rs (tests module): both sides' appended tests kept whole.
2026-10-22 02:24:21 +00:00
aiongg f4e79626fb Merge PR #7160: i18n(ko): translate the 14 counted status messages missing from Korean
crates/ui-egui/src/i18n/mod.rs (tests module): both sides' appended tests kept whole.
2026-10-22 02:24:17 +00:00
aiongg 569cf07753 Integration: #7204 test's StrokeFx literal sets overprint 2026-10-22 02:24:13 +00:00
aiongg 730c1fd8ab Merge PR #7174: Open EXR Parts: the menu asks for a file and shows the parts dialog (#7169) 2026-10-22 02:24:12 +00:00
aiongg 2529a433a0 Merge PR #8371: Fix compound layer property exports in the reference app actions
docs/gaps.md: header line kept from main with the PR's change entry prepended; the PR's change-log row placed above main's 2026-10-21 row.
2026-10-22 02:24:10 +00:00
aiongg 51606201ac Merge PR #3698: feat: animate zoom
crates/ui-egui/src/canvas.rs: crop-camera block and paint_canvas call take the PR's display_view easing; the overlay-while-panning guard (`panning`, `if primary`) is main's, replacing the PR's hand_pan && dragged condition.
2026-10-22 02:24:09 +00:00
aiongg fe28d9d43c Merge PR #7395: Auto-Blend Layers: mask and blend the whole layers, past the canvas too
crates/engine/src/align_cmds.rs (tests module): four interleaved hunks of new tests; resolved as all of ours, then all of theirs, each test whole.
2026-10-22 02:24:08 +00:00
aiongg f271b0e07d Merge PR #7051: Save: ask before replacing a file that an added or lowercased extension names 2026-10-22 02:24:07 +00:00
aiongg b2ae473eab Merge PR #6415: Save for Web: Width, Height, Percent and Resample in Image Size, linked in proportion (#6388) 2026-10-22 02:24:04 +00:00
aiongg e981c71f64 Merge PR #8318: MCP stdio: a line that is not UTF-8 gets a parse error instead of ending the session 2026-10-22 02:24:03 +00:00
aiongg 3db9314ca2 Merge PR #6265: Codecs: save Cineon (.cin) film frames
apps/photocraft/src/services.rs: SAVE_FILTERS keeps the DDS entry from main and the PR's Cineon entry.
crates/codecs/README.md: Cineon table row from the PR (read and write), then main's DICOM and Amiga IFF rows.
crates/codecs/src/format.rs: caps keep the PR's Cineon (read and write) and main's Dicom and Iff rows.
crates/codecs/src/lib.rs: encode keeps the PR's Cineon arm and main's Dicom and Iff arms.
docs/file-format-parity.md: header kept from main with the Cineon write item added; Cineon / DPX row from the PR, DICOM row from main; the PR's change-log line added once at the top.
docs/gaps.md: header kept from main with the Cineon write item added; Missing list without 'Cineon write'; the PR's Cineon progress line replaces main's read-only one; the PR's change-log line added once at the top.
2026-10-22 02:24:03 +00:00
aiongg 86ec88209a Merge PR #8287: Keyboard Shortcuts: the dialog switches sets through the engine too (#8286) 2026-10-22 02:24:03 +00:00
aiongg 8882086fd1 Merge PR #8232: Plug-ins: a reduced live preview scales the settings a manifest marks "unit": "px" (#7524) 2026-10-22 02:24:02 +00:00
aiongg ff2ba37278 Merge PR #8353: Scale shape strokes when resizing for Export As
crates/ui-egui/src/export_dialog.rs: tests appended by both sides; kept both whole.
2026-10-22 02:23:59 +00:00
aiongg 7e37abe1ff Merge PR #8345: New Document: labels show the arrow, not a text cursor (#8331) 2026-10-22 02:23:59 +00:00
aiongg a2fb0d8157 Merge PR #8221: UI: Control-click opens the right-click menus on macOS 2026-10-22 02:23:59 +00:00
aiongg 46adea7eb8 Merge PR #8344: Actions: Photo Filter saves to .atn under the name the reference app runs (#8301)
crates/engine/src/atn_cmds.rs (EVENTS): main builds adjustment events with adjustment_event!; kept main's Black & White macro line and used the PR's by-name Photo Filter (empty code) through the same macro, with the PR's comment.
2026-10-22 02:23:59 +00:00
aiongg 1861cd2261 Integration: #8162 translate the Preview size hover text
The tooltip goes through tl!("Preview size"). All 20 catalogs get the row with the English text (no translations yet). The BrushesPanelState.scale doc comment now names the Brushes tab slider.
2026-10-22 02:23:58 +00:00
aiongg e41060429d Merge PR #7258: fix(atn): reject off-center radial blur in .atn export (#7234)
crates/engine/src/atn_cmds.rs: kept main's helper block (SMART_BRUSH, Color Range, clipping events) whole and the PR's radial blur consts and fns after it.
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole.
2026-10-22 02:23:51 +00:00
aiongg 18bc4ab876 Merge PR #5816: Auto-Blend Layers: blend a layer the canvas crops to all-overlap (#5745)
crates/engine/src/align_cmds.rs (tests module): two interleaved hunks of new tests; resolved as all of ours, then all of theirs, both tests whole.
2026-10-22 02:23:50 +00:00
aiongg 9e698dc272 Merge PR #6269: Type: ⌥ on a paragraph-box handle resizes the block symmetrically
crates/ui-egui/src/type_tool_tests.rs: both sides appended tests at the same place; kept both whole, closing brace of the first test restored.
2026-10-22 02:23:48 +00:00
aiongg 182116cb6d Merge PR #8324: fix(actions): Hue/Saturation uses the reference app's hue key H in .atn files (#8302, #8308)
crates/engine/src/atn_cmds/tests.rs: both sides appended tests; kept both whole.
2026-10-22 02:23:47 +00:00
aiongg bd4e1267a7 Merge PR #8372: Compose: effect maps that don't fit in the effect cache are built over the render rect, not rebuilt whole on every render (#8330) 2026-10-22 02:23:46 +00:00
aiongg 1260845533 Merge PR #8253: test(codecs): cover exact TGA packet and encoder input boundaries 2026-10-22 02:23:46 +00:00
aiongg 8df0e70e3e Merge PR #8168: File names: keep Windows device names (nul, con, com1...) off the disk
crates/engine/src/file_cmds/tests.rs: tests appended by both sides; kept both whole.
2026-10-22 02:23:46 +00:00
aiongg bf1000302d Merge PR #8367: Layers: per-layer XMP metadata (layer.xmpMetadata), stored as the reference app stores it
docs/file-format-parity.md: kept main's PSD row (45 smart filters) and added the PR's layer XMP packet phrase.
2026-10-22 02:23:45 +00:00
aiongg 698aef6bab Merge PR #7396: Auto-Blend Layers: Seamless Tones and Colors turns on gain compensation
crates/engine/src/align_cmds.rs (tests module): both sides' appended tests kept whole.
2026-10-22 02:23:39 +00:00
aiongg 9178e1a744 Merge PR #8162: feat(ui): Brushes tab preview-size slider for grid cells and list rows (#7256) 2026-10-22 02:23:36 +00:00
aiongg 45f556e677 Merge PR #5877: fix(format): ignore accidental ZIP directory signatures in comments 2026-10-22 02:23:35 +00:00
aiongg bf68c22689 Merge PR #6001: Text: admit the rasterizer's peak working bytes (#4979) 2026-10-22 02:23:35 +00:00
aiongg df6e066378 Merge PR #8265: Dialogs: Preferences' title no longer sits against an inner outline (#8199) 2026-10-22 02:23:35 +00:00
aiongg cc390f34de Merge PR #5864: fix(codecs): respect uncompressed DDS row pitch
crates/codecs/README.md: DDS paragraph kept from main (it also describes writing) with the PR's row-pitch sentence added.
2026-10-22 02:23:35 +00:00
aiongg 809ecfcfa3 Merge PR #5951: Type: Create warped text button in the options bar, as in the reference app (#5911)
ATTRIBUTION.md: main's icon-exceptions row with the PR's text-warp.svg added to its list.
2026-10-22 02:23:35 +00:00
aiongg 8f1a8f4f34 Merge PR #8242: Keep Batch results when the action saves and then closes the document
docs/gaps.md: header line kept from main with the PR's change entry prepended and date 2026-10-21; the PR's Done entry and change-log row placed above main's 2026-10-19 ones.
2026-10-22 02:23:34 +00:00
aiongg 2211a01505 Merge PR #8159: fix(ui): menu titles switch on hover at the very top screen edge (#7702) 2026-10-22 02:23:33 +00:00
aiongg dd21538cf5 Merge PR #8119: Alt held during a drag is no menu-bar tap, so Enter commits Free Transform (#8113)
crates/ui-egui/src/menu_keys_tests.rs: both sides appended a test after the same helper; kept both whole (main's alt_held_through_a_shortcut test, then the PR's alt_held_during_a_drag_is_no_tap).
2026-10-22 02:23:31 +00:00
aiongg 223e207642 Merge PR #8142: Quick Export: preserve floating-point EXR output (#7562) 2026-10-22 02:23:29 +00:00
aiongg da305e0c7d Merge PR #5566: fix(ui): keep Character and Paragraph defaults editable before typing
crates/ui-egui/src/tool_reset.rs: main's PaintBucket line (no sample_all_layers) with the PR's Type arm (adds type_style_defaults).
crates/ui-egui/src/type_tool.rs: the PR's styles_at fallback to default_styles, plus main's type_unit line.
docs/gaps.md: header and change-log lines of both (header from main), G9 progress entries of both.
docs/ui-parity.md: header from main, change-log lines of both.
2026-10-22 02:23:26 +00:00
aiongg 9d2612fc01 Merge PR #7611: Refuse strict ATN export of Select and Mask with brush strokes
crates/engine/src/atn_cmds.rs: kept main's smartBrushWorkspace/autoCutout/ClrR events and comment; refineSelectionEdge uses the PR's refine_edge_to, which also refuses a step with sampleAllLayers (main's earlier inline check).
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole.
2026-10-22 02:23:25 +00:00
aiongg 1398aaadff Merge PR #8107: Open/Save: refuse file names that aren't valid Unicode instead of rewriting them (#6601) 2026-10-22 02:23:25 +00:00
aiongg 567a0de150 Merge PR #3532: Move tool: show all six Align icons and an icon menu for Distribute (#3490)
ATTRIBUTION.md: the Lucide row is ours plus `align-edge-*.svg` and `distribute-*.svg` from the PR.
2026-10-22 02:23:23 +00:00
aiongg 095a06aedd Merge PR #5738: Affinity import: budget decoded pixels across the document 2026-10-22 02:23:23 +00:00
aiongg 1a7cd958a9 Merge PR #8190: Preferences: the theme swatch star no longer grows the row on hover 2026-10-22 02:23:21 +00:00
aiongg 2150493f3c Merge PR #7853: Brush: with airbrush build-up on, number keys set Flow and Shift+number sets Opacity (#7785) 2026-10-22 02:23:21 +00:00
aiongg b1cd5e802b Merge PR #8182: fix(actions): .atn export of Image Size with resample none no longer adds a pixel resize (#7189)
crates/engine/src/atn_cmds/tests.rs: both sides appended tests; kept both whole.
2026-10-22 02:23:21 +00:00
aiongg c76bae7f03 Merge PR #5635: Animated WebP: every frame loads into video layers (#1198)
docs/file-format-parity.md: header line kept from main with the animated WebP item added in front; BMP row from main; WebP row from main plus the PR's animation sentence; the PR's change-log line added once, above the first 2026-10-14 row.
2026-10-22 02:23:20 +00:00
aiongg 87fb04da56 Merge PR #7825: Don't paint from the sampled point when Alt is released mid-press 2026-10-22 02:23:20 +00:00
aiongg 189539dc19 Merge PR #8105: Save: a tab whose file another save writes over is detached and marked unsaved (#6597) 2026-10-22 02:23:19 +00:00
aiongg 487c449c5a Merge PR #8284: test(ui): cover tool memory persistence 2026-10-22 02:23:18 +00:00
aiongg 3a26c70d83 Merge PR #8256: test(io): cover PSD palette boundaries and bitmap row packing 2026-10-22 02:23:18 +00:00
aiongg 4e2ad870d3 Merge PR #7614: Previous / Next / First / Last Brush commands (, . ⇧, ⇧.) 2026-10-22 02:23:17 +00:00
aiongg d8acd0579a Integration: #7976 bound the ';' search in xml_unescape to 13 bytes
A long run of '&' without a nearby ';' scanned to the end of the string for every '&' (quadratic). Adds a test with a 200k-ampersand description.
2026-10-22 02:23:17 +00:00
aiongg fd55223656 Merge PR #8237: Clipboard: a panic while reading the clipboard is reported, not a crash (refs #7582) 2026-10-22 02:23:17 +00:00
aiongg 9af659d2f2 Merge PR #3868: feat(ui): nest light and dark themes into submenus
crates/ui-egui/src/i18n/*.tsv (20 catalogs): rows kept both; no duplicate (context, source) keys.
2026-10-22 02:23:14 +00:00
aiongg 1190c5d727 Merge PR #7454: CLI convert: refuse to write over the input file unless --in-place 2026-10-22 02:23:14 +00:00
aiongg 72654dbb09 Merge PR #8236: Add interactive drag positioning to print preview 2026-10-22 02:23:14 +00:00
aiongg e0f7d48b7a Merge PR #8154: fix(ui): Browse… buttons for dialog folder and plug-in path fields (#7942, #7531)
crates/ui-egui/src/view_cmds.rs (form_body): kept main's form_keys(f) key list (honours __order) and the PR's browse variable.
2026-10-22 02:23:13 +00:00
aiongg f443de8cc4 Merge PR #6841: fix(file): treat zero or negative width/height as unconstrained in fitImage 2026-10-22 02:23:13 +00:00
aiongg 9e97c9bd79 Merge PR #8146: fix(psd): an edited type layer updates the the reference app-era Txt2 objects too (#6971) 2026-10-22 02:23:11 +00:00
aiongg 22e9d82465 Merge PR #8258: test(algo): cover Custom convolution bounds, padding and depth semantics 2026-10-22 02:23:09 +00:00
aiongg 35a6222e35 Merge PR #6227: Separate shortcut capture checks from native snapshot rendering 2026-10-22 02:23:09 +00:00
aiongg d1534bbf2c Merge PR #5365: Brush Settings: expose section lock names, state and focus
docs/brush-parity.md: kept main's header and added the PR's change item; the PR's change-log row added.
docs/gaps.md: kept main's header and added the PR's change item; the PR's G4 progress note and change-log row added.
2026-10-22 02:23:09 +00:00
aiongg 74a2cd3baf Merge PR #8143: Compose: adjustment layers blend back and round without per-sample calls 2026-10-22 02:23:08 +00:00
aiongg a1274ac043 Merge PR #8135: Compose: tone and hue-range lookups truncate instead of calling floor 2026-10-22 02:23:07 +00:00
aiongg b43ba11beb Merge PR #8109: Compose: Color Balance works out each channel's curve once, not per pixel 2026-10-22 02:23:07 +00:00
aiongg 3d71c3a1c0 Merge PR #8013: perf(raster): read 16- and 32-bit rows into RGBA with the colour model resolved per row 2026-10-22 02:23:07 +00:00
aiongg 2b3af0f815 Merge PR #3193: fix(compose): resolve Dissolve coverage across adjustment, effects, a…
Conflicts:
crates/compose/src/tests.rs: appended tests, kept both
crates/gpu/tests/parity.rs: appended tests, kept both
2026-10-22 02:23:06 +00:00
aiongg 11b1594050 Merge PR #7835: Print: the no-listener test takes a port the OS says is free, not port 1 2026-10-22 02:23:02 +00:00
aiongg aebb0b3866 Merge PR #7831: Print: an IPv6 print server is parsed as a host, not split on its last colon 2026-10-22 02:23:02 +00:00
aiongg 9a9aab3bb3 Merge PR #7976: Decode numeric character references in File Info XMP text 2026-10-22 02:23:01 +00:00
aiongg df07c57a0d Merge PR #7979: Preferences: say Rendering Mode applies at next launch, next to it 2026-10-22 02:23:01 +00:00
aiongg c50708ea08 Merge PR #7242: A second launch that sets only environment overrides keeps its own process
apps/photocraft/src/single_instance.rs: both sides' appended tests kept whole.
2026-10-22 02:23:01 +00:00
aiongg a5da71b594 Merge PR #7871: fix(touch) double tap jitter 2026-10-22 02:23:01 +00:00
aiongg ef23450443 Merge PR #5882: Menus: Chinese, Japanese and Korean labels get access keys, "文件(F)" (#5863) 2026-10-22 02:23:00 +00:00
aiongg b119da85cb Merge PR #7801: Refuse strict .atn export of an in-place layer duplicate
contributors/people.toml: both sides appended a person; kept both.
2026-10-22 02:23:00 +00:00
aiongg 2d74af4c8e Merge PR #7721: fix(actions): refuse lossy fractional Crystallize ATN export
crates/engine/src/atn_cmds.rs: kept main's fields_from/fields_to doc comments and range-checked Kind::Long arm; one ExactLong definition (main's); Ripple Amnt stays ExactLong and Crystallize ClSz becomes ExactLong.
2026-10-22 02:22:59 +00:00
aiongg 11c3f1ec92 Merge PR #5180: Type tool: click a path to start type on it (TYPE-218-1, #218)
contributors/people.toml: union of both sides' entries.
crates/ui-egui/src/canvas.rs: main's Move cursor (arrow) plus the PR's Type path-cursor arm.
crates/ui-egui/src/type_tool.rs: main's has_transform in draw_overlay; the PR's earlier `shape` binding kept, the duplicate binding dropped.
crates/ui-egui/src/vector_ui.rs: main's draw_overlay (paths already show with any tool), the PR's vector_tool line dropped.
docs/gaps.md: header line from main, G8 text from main with the click-on-path clause, change-log lines of both.
2026-10-22 02:22:58 +00:00
aiongg ed69a40204 Merge PR #8257: test(algo): cover deinterlace choices and NTSC signal limits 2026-10-22 02:22:58 +00:00
aiongg c013fa29d4 Merge PR #5888: Pen options bar: Make Selection in Path mode (#5703) 2026-10-22 02:22:57 +00:00
aiongg 86ff9317c0 Merge PR #8187: fix(ui): a unit after a range bound in a params doc no longer resets the dialog range (part of #7598) 2026-10-22 02:22:57 +00:00
aiongg ec1c38dfd1 Merge PR #8128: Liquify: keep short strokes whole so undo removes only the last
crates/ui-egui/src/liquify_ui.rs: tests appended by both sides (zoom tests from #7227 and this PR's stroke tests), both kept whole.
2026-10-22 02:22:56 +00:00
aiongg 8d989c4571 Merge PR #7894: Slice Select: Delete deletes the selected slice, not the layer (#7862)
crates/ui-egui/src/slice_ui.rs: tests appended by both sides; kept both whole.
2026-10-22 02:22:55 +00:00
aiongg 96cce9b2f9 Merge PR #4713: Codecs: open Amiga IFF/ILBM bitmaps read-only
apps/photocraft-web/src/web.rs: OPEN_EXTS kept main's list (with the DICOM extensions) and added iff, lbm, ilbm.
apps/photocraft/src/services.rs: OPEN_EXTS kept main's list (with the DICOM extensions) and added iff, lbm, ilbm.
crates/codecs/README.md: kept both the DICOM and the Amiga IFF table row and section.
crates/codecs/src/format.rs: kept both the Dicom and the Iff variants in every match, detect runs the DICOM check then the IFF check, Format::ALL length re-counted (22).
crates/codecs/src/lib.rs: one use line importing dicom and iff; both decode and encode arms kept.
docs/gaps.md: header line kept from main, with the PR's 'Cineon and Amiga IFF read' wording.
2026-10-22 02:22:54 +00:00
aiongg 0cafffbfcd Merge PR #7161: i18n(zh-hans): translate the 14 counted status messages missing from Simplified Chinese 2026-10-22 02:22:54 +00:00
aiongg 5b08c0ce79 Merge PR #7811: Preferences › Raw Defaults › Resolution: a developed raw opens at it (#7789)
docs/scorecard.md: ours (regenerated at combine).
2026-10-22 02:22:53 +00:00
aiongg 0fa9f075df Merge PR #8173: Layers panel: mark adjustment layers kept as imported but not rendered (#6541) 2026-10-22 02:22:51 +00:00
aiongg c08a6ad9cf Merge PR #5317: Brush Pose: preserve independent Tilt X/Y overrides
crates/paint/src/brush_tests.rs: both sides added tests at the same spot; kept main's four smoothing tests and added the PR's three pose_axes tests after them.
docs/brush-parity.md: kept main's header and added the PR's change item.
docs/gaps.md: kept main's header, added the PR's change item; G4 progress notes and the change-log row kept from both sides.
docs/control-protocol.md: kept main's eyedropper line (with the document.sampleColor wording) and added the PR's Brush Pose line before it.
2026-10-22 02:22:51 +00:00
aiongg e752bb2ec8 Merge PR #6661: fix(print): preserve WinAnsi characters in PDF labels
contributors/people.toml: both sides' new rows kept.
crates/engine/src/print_cmds/tests.rs: both sides' appended tests kept whole.
2026-10-22 02:22:51 +00:00
aiongg a53c1ed1d7 Merge PR #7759: Actions: a setting the reference app's event cannot hold refuses the step
crates/engine/src/atn_cmds.rs: the filter macro's to guard keeps both checks (pixel_target && !skip && holds_its_settings); kept main's preserves_roundness and the PR's UNHOLDABLE table and holds_its_settings.
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole.
2026-10-22 02:22:51 +00:00
aiongg 46b1cda2d6 Merge PR #7621: Play .atn steps that switch to another open document
crates/engine/src/atn_cmds.rs: three hunks, kept both sides (the Mn menu-item mapping and zoom ids from main, the Dcmn offset mapping and document.activate id from the PR); the document.activate arm precedes main's menu-item fallback in select_to.
docs/gaps.md: header line kept from main with the PR's change entry prepended and the date 2026-10-19.
2026-10-22 02:22:50 +00:00
aiongg 334636fc1e Merge PR #8085: Preferences › File Handling: Do not append "copy" to filename when saving a copy (#8080)
docs/scorecard.md: ours (regenerated at combine).
2026-10-22 02:22:50 +00:00
aiongg 5f67e5da81 Merge PR #8030: Layers panel: a group's layers indent past the group (#8017)
crates/ui-egui/src/panels.rs: layer_row: kept main's thumbnail-size lines (base == 0 case) and applied the PR's one-step indent through its own steps value.
2026-10-22 02:22:50 +00:00
aiongg aa4f03a8ad Merge PR #7571: Refuse strict .atn export of Apply Image with a blending mask
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended tests, kept both
2026-10-22 02:22:48 +00:00
aiongg 8c92678a30 Merge PR #7815: PNG: correct sTER eye-order names without changing stored bytes 2026-10-22 02:22:43 +00:00
aiongg 7c774bef43 Merge PR #7677: Export As: show the size the file will have (#6595)
crates/ui-egui/src/export_dialog.rs: the cache_preview call takes both sides' changes (main's filter argument, the PR's sig.clone()).
2026-10-22 02:22:43 +00:00
aiongg adf7e6ee8b Merge PR #7956: Align: a lone layer in an artboard aligns to that artboard 2026-10-22 02:22:42 +00:00
aiongg eedeacb36e Merge PR #8087: fix(liquify): include cubic overshoot in source reads
crates/algo/src/liquify.rs: apply_liquify keeps this PR's widened maxd and main's has_freeze line (both lines kept).
2026-10-22 02:22:40 +00:00
aiongg a805bbcb14 Merge PR #7932: Layers: the pointer over a thumbnail shows what Cmd-click loads, with the +/-/x badge (#7824)
crates/ui-egui/src/layer_shortcut_tests.rs: both sides appended a test at the end; kept both whole.
2026-10-22 02:22:40 +00:00
aiongg 683490f5de Merge PR #6445: Tests: every GPU test holds gpu_lock (fixes STATUS_ACCESS_VIOLATION crashes on Windows) 2026-10-22 02:22:34 +00:00
aiongg 37d7ff1b2e Merge PR #8097: i18n(pt-br): translate 131 runtime labels that still showed in English 2026-10-22 02:22:34 +00:00
aiongg 029e97951f Merge PR #6389: Print: Functions › Bleed (corner crop marks on the trim line)
crates/engine/src/print_cmds.rs: kept main's result fields and the printServer/spooler/backend parameter text, added the bleed field and the bleed parameter text (two hunks).
crates/engine/src/print_cmds/tests.rs: both sides' appended tests kept whole.
docs/target-app-parity.md: header from main with the Bleed item added; change-log rows from both sides, newest first.
2026-10-22 02:22:34 +00:00
aiongg a40972d80d Merge PR #7585: Refuse strict .atn export of a mask/channel-targeted Free Transform
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended tests, kept both
2026-10-22 02:22:32 +00:00
aiongg e1fde5bfa2 Merge PR #7930: perf(liquify): parallel dab nodes and source read, P30 −13–15 %, same pixels 2026-10-22 02:22:32 +00:00
aiongg 80071286af Merge PR #8114: Layers panel: a click on the empty space below the layers deselects them (#8100)
crates/ui-egui/src/layer_shortcut_tests.rs: both sides' appended tests kept whole.
2026-10-22 02:22:31 +00:00
aiongg b66199332f Merge PR #7588: fix(atn): refuse strict export of options the descriptors can't carry
crates/engine/src/atn_cmds.rs: kept this PR's range-checked Kind::Long arm and main's Kind::ExactLong arm; dropped the PR's Group Layers explicit-layer refusal (#7236), as #7968 exports that layer by name.
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole; dropped the PR's groupLayers layer:3 refusal case (#7236).
2026-10-22 02:22:30 +00:00
aiongg 2121cfecd0 Integration: #7665 the near-one-second exposure test expects three significant digits
Main's test expected 0.996 s to print as "1 s" (two decimals); the PR prints three significant digits, so it reads "0.996 s". Still never "1/1 s".
2026-10-22 02:22:26 +00:00
aiongg 4054507e76 Merge PR #7919: Liquify: Alt-drag brush resize keeps the brush in place and no longer paints a dab (#7884) 2026-10-22 02:22:26 +00:00
aiongg b0c055c728 Merge PR #7624: Fade the alpha channel or Quick Mask an edit changed (#7382)
crates/engine/src/edit_menu_cmds.rs: fade(): kept main's checked_sub for the previous state and took the PR's FadeTarget-based surface_of lookups and messages.
2026-10-22 02:22:25 +00:00
aiongg 536a866e7c Integration: #7808 complete the RowGestures literal with ..Default::default() 2026-10-22 02:22:23 +00:00
aiongg 1e24b4d743 Merge PR #7593: Refuse .atn export of crops with output size or resolution
crates/engine/src/atn_cmds/tests.rs: both sides appended tests; kept both whole.
2026-10-22 02:22:22 +00:00
aiongg 96cc40a362 Merge PR #8270: docs(support): Canvas Size added a white border around my photo 2026-10-22 02:22:21 +00:00
aiongg 72ca30ab77 Merge PR #8269: contributors: add adi-1108
contributors/people.toml: both sides appended entries at the end; kept both (union).
2026-10-22 02:22:20 +00:00
aiongg e1492a75f4 Merge PR #7665: File Info: print a slow shutter speed as the fraction a camera writes
crates/engine/src/file_cmds.rs: exposure_text kept the PR's fraction rule and main's guard against "1/1 s" (n > 1.0 for exposures from 0.1 s to 1 s); the main test for it still holds.
2026-10-22 02:22:19 +00:00
aiongg 1e5866ebaf Merge PR #4567: Codecs: PCX runs may cross plane boundaries within a scanline
contributors/people.toml: union; main's new entries kept and the PR's webdevtodayjason entry appended.
2026-10-22 02:22:17 +00:00
aiongg 1cbeb4a784 Merge PR #7590: fix(atn): keep a Transform Selection's interpolation; refuse what the transform descriptor can't carry
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended tests, kept both
2026-10-22 02:22:16 +00:00
aiongg 1f01abeebe Merge PR #7808: Add a draggable center diagram for Radial Blur Zoom 2026-10-22 02:22:16 +00:00
aiongg 2bf3e4e0be Merge PR #5470: feat(tiff): a tiffCompression option for scripted TIFF saves
crates/engine/src/file_cmds/tests.rs: both sides' appended tests kept whole.
docs/file-format-parity.md: header line keeps ours plus the PR's change item, date 2026-10-18; TIFF row has both notes; change-log rows kept both, newest first.
2026-10-22 02:22:15 +00:00
aiongg 7b9011ca33 Merge PR #8252: docs(architecture): drop references to the removed rust-framework-options.md 2026-10-22 02:22:15 +00:00
aiongg b09232adb1 Merge PR #8188: docs(parity): Crop rows in ui-parity.md match the code and the scorecard (#6562)
docs/ui-parity.md: header Change line kept ours (newer items) and the PR's Crop item added once at the front; revision-history row from the PR kept.
2026-10-22 02:22:15 +00:00
aiongg ae539ac9a9 Merge PR #5199: Brush Settings: disable Wetness/Mix controls outside Mixer Brush
docs/brush-parity.md: kept main's header, added the PR's change item and its newer date.
docs/gaps.md: kept main's header, added the PR's change item; change-log row appended after main's rows.
2026-10-22 02:22:13 +00:00
aiongg 7b7628adaa Integration: #7204 outside-stroke paint composites as Normal in Dissolve mode (share carries the speckle; CPU and GPU agree) 2026-10-22 02:22:13 +00:00
aiongg f0252fee5c Merge PR #7227: Liquify: zoom the preview with the zoom keys, a trackpad pinch and ×1.1 wheel notches (#7210) 2026-10-22 02:22:12 +00:00
aiongg 67cb5ec9eb Merge PR #5131: i18n: translate missing Simplified Chinese tool labels 2026-10-22 02:22:10 +00:00
aiongg 916fc864c0 Merge PR #7109: Presets: the damaged-set test also covers Export As presets
crates/engine/src/presets/tests.rs: main's test already covered export_presets; kept main's setup and loop, added the PR's recent_documents key and its extra damaged/null assertions, dropped the PR's unconditional per-key export_presets assert (main's conditional one stays).
2026-10-22 02:22:10 +00:00
aiongg c97ffe307e Integration: #4315 refuse a HighBit outside the allocated bits
An out-of-range HighBit made the sample shift overflow. Adds a test with HighBit 200.
2026-10-22 02:22:10 +00:00
aiongg cce5208c2a Merge PR #7167: fix(ui): right-click an Adaptive Wide Angle constraint for its orientation menu, as in the reference app 2026-10-22 02:22:09 +00:00
aiongg 34b7890896 Merge PR #7153: Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace 2026-10-22 02:22:07 +00:00
aiongg 2a277af23c Merge PR #5062: fix(i18n): Italian Screen blend mode is "Scherma", not "Schermo"
contributors/people.toml: union of both sides' new entries.
2026-10-22 02:22:06 +00:00
aiongg ec663f4981 Merge PR #7204: Layer effects in Dissolve mode speckle, as in the reference app (#6995) 2026-10-22 02:22:05 +00:00
aiongg 875cb4ee67 Merge PR #6994: Relink to File: ask for the file (menu bar and layer context menu) 2026-10-22 02:22:03 +00:00
aiongg 65df7e8603 Merge PR #7072: Layer Style: keep gradient overlay colours when reopening the dialog (#7031) 2026-10-22 02:22:03 +00:00
aiongg 1da47d45bd Merge PR #7112: docs: fix subject-verb agreement in downloads section 2026-10-22 02:22:02 +00:00
aiongg b4b21df0ca Merge PR #6925: fix(engine): preserve Background layer on Merge Layers 2026-10-22 02:22:02 +00:00
aiongg 586b4f59a2 Merge PR #6946: docs(xtask): point the layers help text at docs/architecture.md 2026-10-22 02:22:02 +00:00
aiongg b4e5eee177 Merge PR #6891: Reorder Layer Comps through dragging and context actions (#6498) 2026-10-22 02:22:02 +00:00
aiongg 91966f1ac7 Merge PR #6824: docs(contributors): add Gotix-eg to people.toml
contributors/people.toml: both sides appended entries at the end; kept both (union).
2026-10-22 02:22:02 +00:00
aiongg 5ff4d17b1b Merge PR #6136: chore: remove unused deps and dead code
crates/engine/src/float_cmds.rs: kept main's arm and dropped only the dead "edit.stepBackward" id (edit.undo | history.stepBackward).
2026-10-22 02:22:01 +00:00
aiongg 537d29074d Merge PR #6880: Disable Convert to Smart Object for layers that are already smart objects 2026-10-22 02:22:01 +00:00
aiongg 41bab40fe0 Merge PR #6508: Layer Comps: double-clicking a comp's name renames it in place 2026-10-22 02:22:01 +00:00
aiongg 4502ff75d6 Merge PR #6457: fix(engine): keep comps, slices and variables when converting to a smart object 2026-10-22 02:22:01 +00:00
aiongg 4484a07a5c Merge PR #6440: fix(engine): convert the background and match mask depth where they were missed 2026-10-22 02:22:00 +00:00
aiongg b3ef6fb862 Merge PR #6794: feat(ui): System Info shows the graphics card's own memory 2026-10-22 02:21:59 +00:00
aiongg 9adacf078f Integration: #4474 drop the two fr.tsv rows already on main (Cross, Lock Layers) 2026-10-22 02:21:59 +00:00
aiongg 250ade19b6 Merge PR #6253: fix(compose): interior effects see the backdrop through a Normal layer's fill 2026-10-22 02:21:59 +00:00
aiongg 4a3d69ac74 Merge PR #6455: fix(engine): match smart object re-renders to the document's mode and depth 2026-10-22 02:21:58 +00:00
aiongg 22e55823f9 Merge PR #6112: fix(compose): stop effect noise from painting empty blur tails 2026-10-22 02:21:58 +00:00
aiongg e77799ab4f Merge PR #5940: History panel: scroll to the current state on document change (#5909) 2026-10-22 02:21:57 +00:00
aiongg 3d1f65123e Merge PR #6829: Actions: Shift-click layer select in .atn imports as range select and exports back (#6784) 2026-10-22 02:21:56 +00:00
aiongg 61adf55731 Merge PR #6433: fix(engine): keep the rendering of smart filters that cannot be reproduced 2026-10-22 02:21:56 +00:00
aiongg 5947f4c69f Merge PR #6295: Smart Filters: Delete/Backspace removes the selected filter, not its smart object (#4751)
docs/ui-parity.md: header line kept from main, plus this PR's change-log entry once, first.
2026-10-22 02:21:56 +00:00
aiongg 6afb66de55 Merge PR #3775: Control protocol: document.pixel / document.sampleColor reply ranges (#3666)
docs/control-protocol.md: kept main's table rows (document.inspect with snapshots, layer.list) and added the PR's four-floats wording to document.pixel.
2026-10-22 02:21:55 +00:00
aiongg 36443b13ed Merge PR #6726: macOS: Beep When Done plays the system alert sound (NSBeep) instead of a terminal bell (#6537) 2026-10-22 02:21:55 +00:00
aiongg e8c68586c6 Merge PR #5644: Layer Mask Display Options: set the layer mask rubylith's colour and opacity
docs/scorecard.md: kept ours (regenerated at combine).
2026-10-22 02:21:54 +00:00
aiongg 371c912c70 Merge PR #7260: test(io): SVG text-transform test can't pass without a font (#4806) 2026-10-22 02:21:53 +00:00
aiongg f1fda63895 Merge PR #7241: Layer Export As: the dialog opens with the layer's size, not the document's
crates/ui-egui/src/export_dialog.rs: tests appended by both sides; kept both whole.
2026-10-22 02:21:53 +00:00
aiongg 7b50fbd796 Merge PR #4315: Codecs: open uncompressed DICOM single-frame images (#1048)
apps/photocraft-web/src/web.rs: OPEN_EXTS kept main's list and added dcm, dicom, dc3 after cin.
apps/photocraft/src/services.rs: OPEN_EXTS kept main's list and added dcm, dicom, dc3 after cin.
2026-10-22 02:21:52 +00:00
aiongg 5e80f833c1 Merge PR #6150: File Info: the reference app field order and labels (#6125, #6126)
crates/ui-egui/src/view_cmds/tests.rs: both sides appended tests; kept both whole.
2026-10-22 02:21:52 +00:00
aiongg e275e4f8d7 Merge PR #4474: i18n: complete the French (fr) UI translation (1,041 entries) 2026-10-22 02:21:49 +00:00
aiongg 8fc95d1b1c Merge PR #6390: Contributors: add Marco Arnulfo 2026-10-22 02:21:48 +00:00
aiongg 8de9965e6b Merge PR #4991: Deselect layers when clicking the pasteboard with Move Auto-Select 2026-10-22 02:21:48 +00:00
aiongg 995eae3123 Merge PR #3559: Type: wire Recent Fonts into the font menu (#204)
docs/scorecard.md: ours (regenerated at combine).
2026-10-22 02:21:48 +00:00
aiongg 15df0acb04 Merge PR #4585: Prevent clipboard centering overflow and incorrect paste placement 2026-10-22 02:21:47 +00:00
aiongg db6fc62d58 Merge PR #3565: M11: ten more acceptance tasks (21-30) in a second file for FILE-216-6 2026-10-22 02:21:46 +00:00
aiongg 200206f56b Merge PR #4392: Layer Style: user contour library with New…, Load/Replace/Save Contours (#4391) 2026-10-22 02:21:46 +00:00
aiongg 8d3d944ad1 Merge PR #5372: Credit Abdul karim mia in contributors 2026-10-22 02:21:45 +00:00
aiongg daeccbb3c3 Merge PR #5064: docs(perf): how to measure one scenario and read its numbers 2026-10-22 02:21:45 +00:00
aiongg de050bcf6b Merge PR #4219: SVG import: set text in an installed font when the named ones are missing 2026-10-22 02:21:39 +00:00
aiongg 3458eee861 Merge main into #3283
dialogs.rs: main's Displace-map prompt runs before the fallible confirm_command. filter_dialog.rs: main's ask_displace_map uses the new confirm_command signature; SMART_FILTER_SET_PARAMS stays for main's uses; a smart-filter edit without a "layer" param previews the active layer, as the command runs it (main's smart_displace_preview_uses_the_map_document).
2026-10-22 02:16:49 +00:00
Khawar Zamman WaniandClaude Opus 5.5 57e95b63c2 test(canvas): marching-ants pixel tests skip only when no GPU adapter exists
catch_unwind wrapped the app callback too, so a panic in setup_context,
PhotocraftApp::new or set_wgpu printed "no GPU adapter" and passed both
tests untested. A panic after the callback starts is now re-raised.
Addresses CodeRabbit's review on #7530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:41:26 +05:30
Khawar Zamman WaniandClaude Opus 5.5 1f63edd1b6 Merge remote-tracking branch 'origin/main' into hand-tool-modifier-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 06:28:54 +05:30
Khawar Zamman WaniandClaude Opus 5.5 df0b3e63cc Merge remote-tracking branch 'origin/main' into field-wheel-5473
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 06:28:54 +05:30
Khawar Zamman WaniandClaude Opus 5.5 fb12ed9322 Merge remote-tracking branch 'origin/main' into fix/marching-ants-device-pixels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 06:28:53 +05:30
Zuujis 38bc5e916c refactor(atn): build the clipping-mask and three adjustment events with macros (#8401)
Create/Release Clipping Mask and Brightness/Contrast, Black & White and
Gradient Map were written out by hand with the same body as
adjustment_event! or each other, which SonarCloud counts as duplicated new
code (photocraft's quality gate fails on 9.8% duplication from the GrpL
entry). adjustment_event! takes an optional command id, and clipping_event!
builds the two clipping events. The mapping table is unchanged.
2026-10-21 20:28:12 -04:00
Dylan M. Taylor f4bc90b93f ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#8368)
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
2026-10-21 20:12:22 -04:00
Dylan M. Taylor 3ec52a9ff1 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#8369)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 20:11:03 -04:00
jonathanwxh-cell e7bf017502 Merge main into batch save-point fix, retaining both regression suites 2026-10-22 07:47:27 +08:00
Dylan M. Taylor 9b18f57344 test: use tokio::time::sleep in async test poll loop (SonarCloud rust:S7488) (#8373)
The multi-thread #[tokio::test] polled with a blocking std::thread::sleep,
stalling a runtime worker. Use tokio::time::sleep(..).await instead.
2026-10-21 19:35:38 -04:00
aiongg ad694eb17d Merge pull request #8370 from storytold/sonar-fix/actions-script-injection
security: prevent script injection in fuzz workflow (SonarCloud S7630)
2026-10-21 19:26:09 -04:00
aiongg ba40dffb25 Merge pull request #8355 from aiongg/integration-10
Integration batch: #8229, David's follow-ups and 85 FredApps-approved PRs on main
2026-10-21 19:02:54 -04:00
aiongg ce85a5ae78 Merge PR #8229: Integration batch: 8 PRs on main 2026-10-21 22:30:00 +00:00
aiongg 33214b70f4 Merge PR #6708: Paths: the path selected in the Paths panel is drawn on the canvas (#6550) 2026-10-21 22:30:00 +00:00
aiongg 989a685b3f Merge PR #6208: Preferences: Save in Background off saves in the foreground (#6207) 2026-10-21 22:29:59 +00:00
aiongg 3b04ff873c Merge PR #5840: Export As: Resample menu, incl. Nearest Neighbor (#5838) 2026-10-21 22:29:59 +00:00
abc2cbandClaude Opus 5.5 08fa0ed4fb Smart Filters: keep main's thumbnail scaling tests test-only
The merge of main kept `#[cfg(test)]` on `mod editor_tests;` only, so
`layer_thumbnail_scaling_tests` (which uses the dev-only egui_kittest)
was compiled into normal builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:13:10 +03:00
abc2cbandClaude Opus 5.5 a335b58e21 Merge upstream/main 43b14883 into the Smart Filters branch
#3289, #3974 and #3975 landed in main, so the Smart Filters row code meets
#3974's thumbnail geometry.

Conflicts:
- smart_ui.rs: the rows keep our layout (separate name and Blending
  Options targets, shared disclosure, real mask thumbnail) and take
  #3974's geometry: the header height and the shared-mask side come from
  smart_filters_header_geometry, sub-rows are SUB_ROW_H, the mask is
  hidden at None and the header name follows it. #3974's own
  drag_started and selection handling is dropped, ours already covers
  both through the name target. #3974's scaling test module is kept
  next to editor_tests.
- All 20 i18n catalogs: both sides appended rows at the end; kept both,
  exact duplicates dropped, dupscan reports 0 problems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:29 +03:00
vangel76andClaude Sonnet 5.5 3749c7d70f Clear texture deltas in the contour name-draft test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 00:04:59 +02:00
aiongg f32f882f3a Merge main into integration-10
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	crates/engine/src/file_cmds/tests.rs
#	crates/engine/src/float_cmds.rs
#	crates/engine/src/float_cmds/tests.rs
#	xtask/src/corpus.rs
#	xtask/src/corpus_pins.rs
#	xtask/src/main.rs
2026-10-21 21:56:26 +00:00
aiongg 21a952698a Integration: Lens Flare and the render filters' colours read with main's double and typed enum_name helpers, for #6160 2026-10-21 21:56:22 +00:00
aiongg 1a388f8ba7 Merge PR #8229: Integration batch: 8 PRs on main
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
2026-10-21 21:56:22 +00:00
aiongg 64d042aef5 Integration: Create Clipping Mask also maps the GrpL code and a named-layer target, for #6881 and #4496 2026-10-21 21:56:21 +00:00
aiongg 0e9cbbc88e Integration: one tools.setColors and one clipping-mask mapping in .atn export, for #6881 and #4496 2026-10-21 21:56:21 +00:00
aiongg 719fb44d42 Merge PR #6881: Actions: .atn Select Subject, Select and Mask, Color Range, clipping and layer masks, colours map to commands (part of #6745) 2026-10-21 21:56:21 +00:00
aiongg 3b16c2bc98 Merge PR #5729: Move tool: ΔX/ΔY readout beside the pointer while dragging (#5727) 2026-10-21 21:56:21 +00:00
aiongg bd133eee81 Merge PR #5855: Displace: OK asks for the displacement map, as in Photoshop (#5806) 2026-10-21 21:56:21 +00:00
aiongg 43e9587590 Merge PR #6363: New Document: Color Profile, the working space or another profile of the mode (#321) 2026-10-21 21:56:21 +00:00
aiongg 20adb5f1d7 Merge PR #6410: Artboards: layers outside the boards stay visible over the pasteboard (#5712) 2026-10-21 21:56:21 +00:00
aiongg 1bc45862cb Merge PR #6813: A layer that doesn't fit in GPU memory no longer breaks GPU drawing: older pages are freed and the layer is retried 2026-10-21 21:56:21 +00:00
aiongg 64526a6c28 Merge PR #7904: Filter Gallery › Halftone Pattern: a tone-keeping screen at 45° 2026-10-21 21:56:21 +00:00
aiongg f97c28828d Merge PR #6873: HEIF: honour the nclx colour description; HLG/PQ HEICs open as linear BT.2020 32-bit (#6752) 2026-10-21 21:56:21 +00:00
aiongg 1837a19362 Merge PR #6908: Background redraw: keep the last finished composites, so a solo's restore uploads instead of recompositing (part of #6528) 2026-10-21 21:56:21 +00:00
aiongg 29388de427 Merge PR #6919: Selections don't mark a document modified (#6865) 2026-10-21 21:56:21 +00:00
aiongg c71322043c Merge PR #7941: Light themes outline the slider knob (UI-217-11, #7927) 2026-10-21 21:56:21 +00:00
aiongg e4a413241a Merge PR #8004: Windows on ARM: auto picks DirectX 12 for Qualcomm graphics (#7537) 2026-10-21 21:56:21 +00:00
aiongg 403dc5043a Merge PR #8096: CodeRabbit follow-ups for the #8095 batch 2026-10-21 21:56:21 +00:00
aiongg 07e4020360 Merge PR #8098: PSD layer comps: an entry without enab inherits the previous visibility, as in Photoshop (#8093) 2026-10-21 21:56:21 +00:00
aiongg 3b6fabb04a Merge PR #6801: Actions that start with Deselect play on when nothing is selected 2026-10-21 21:56:21 +00:00
aiongg a06c4639f6 Merge PR #5590: Warp: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) 2026-10-21 21:56:21 +00:00
aiongg 7b81efddcb Merge PR #5436: IO: open .psd through the PSD reader, not codecs (#5258) 2026-10-21 21:56:21 +00:00
aiongg dd83f87d3c Merge PR #5581: Layers: Group Layers creates the group collapsed (#5571) 2026-10-21 21:56:20 +00:00
aiongg 0fe1d3233d Merge PR #5907: Text: apply anti-alias mode shaping when rasterizing type (#5902) 2026-10-21 21:56:20 +00:00
aiongg c865e3b93b Merge PR #5928: BMP export: Indexed Color documents save as palette BMPs 2026-10-21 21:56:20 +00:00
aiongg b9b97f15ff Merge PR #6243: GPU: pin the keep-overlap ordering with a tile-growth regression test 2026-10-21 21:56:20 +00:00
aiongg 5eada4996a Merge PR #7219: Actions: record File › Export › Export As and replay it with its settings (#7207) 2026-10-21 21:56:20 +00:00
aiongg abfb8cce96 Merge PR #8042: Codecs: Fix corrupted CMYK JPEG exports (#8008) 2026-10-21 21:56:11 +00:00
aiongg fa5a06de4f Merge PR #8053: Pencil: align 1 px click snapping with the line walker (#4895) 2026-10-21 21:56:11 +00:00
aiongg fb9da6d581 Merge PR #7303: Keyboard Shortcuts: Shift is recorded when capturing a shortcut 2026-10-21 21:56:11 +00:00
aiongg a870371600 Merge PR #7270: Brushes: folders with search matches can be collapsed while searching 2026-10-21 21:56:11 +00:00
aiongg f38e0e40bd Merge PR #7304: Freeform Pen: ⇧ constrains freehand strokes to straight 45° lines 2026-10-21 21:56:11 +00:00
aiongg 9ce8ab897c Merge PR #7317: Color panel: RGB slider mode (#7172) 2026-10-21 21:56:11 +00:00
aiongg a0d6bf2dcd Merge PR #7791: Camera raw: a developed raw keeps its EXIF (RW2 included), so File Info shows its Camera Data (#6957) 2026-10-21 21:56:11 +00:00
aiongg b75f0cf21d Merge PR #7799: Displace on a Smart Object: the map is embedded in the smart filter, so it displaces (#7423) 2026-10-21 21:56:10 +00:00
aiongg f3c20b9242 Merge PR #7626: Flatpak: a second launch hands its files to the running PhotoCraft over a Unix socket (#1540) 2026-10-21 21:56:10 +00:00
aiongg b0833a893c Merge PR #7694: perf(format): reuse .pcraft writers between document saves 2026-10-21 21:56:10 +00:00
aiongg 5189019b5b Merge PR #7696: fix(actions): preserve effective Stroke width in ATN export 2026-10-21 21:56:10 +00:00
aiongg c2d5a3fd64 Merge PR #7708: fix(actions): refuse lossy fractional Ripple ATN export 2026-10-21 21:56:10 +00:00
aiongg 7b4dfb7ff6 Merge PR #7980: perf(raster): tile content bounds compare rows in blocks (P32, #211) 2026-10-21 21:56:10 +00:00
aiongg 6695bd5a8b Merge PR #8005: Open: a file without type never waits for the font scan (#7332) 2026-10-21 21:56:10 +00:00
aiongg f8bea646d4 Merge PR #8009: automation: edit.installedProfiles is no host-path command 2026-10-21 21:56:10 +00:00
aiongg 1d278f3061 Merge PR #8012: fix(liquify): keep frozen pixels stable near mask boundaries (#7989) 2026-10-21 21:56:10 +00:00
aiongg a194e0ebb2 Merge PR #7772: Vanishing Point: perspective clone samples without allocating per pixel (#7491) 2026-10-21 21:56:10 +00:00
aiongg 7ab402c025 Merge PR #6028: Zoom tool menu: Zoom In and Zoom Out last, as in Photoshop (#6005)
# Conflicts:
#	docs/context-menu-parity.md
2026-10-21 21:56:02 +00:00
aiongg 4a04cb6d14 Merge PR #6146: refactor: share hsb conversions between photocraft-color and the color picker 2026-10-21 21:55:49 +00:00
aiongg 3a7180cf8c Merge PR #6509: Type: commit the edit when another layer or document becomes active (#6483) 2026-10-21 21:55:49 +00:00
aiongg 8bb5f31e73 Merge PR #6830: Tab in Full Screen Mode shows the menu bar and panels; leaving Full Screen restores them as before (#6760) 2026-10-21 21:55:49 +00:00
aiongg 419f6ef4c7 Merge PR #6910: Web: one press per tap, so menus open again on touch (#2095) 2026-10-21 21:55:49 +00:00
aiongg 5ef5c71164 Merge PR #6912: Shortcuts: custom bindings on PageUp/PageDown (and other multi-word keys) fire again (#6827) 2026-10-21 21:55:49 +00:00
aiongg e09bf5728f Merge PR #7784: Actions: a background filter's step is recorded only by the recording it started in (#7289) 2026-10-21 21:55:49 +00:00
aiongg 97a1a67f38 Merge PR #7959: Actions: Emboss saves to and loads from .atn as Photoshop's Embs step (#7946) 2026-10-21 21:55:49 +00:00
aiongg f0137c6ace Merge PR #7963: Fix File Info showing 1/1 s for exposures just under one second 2026-10-21 21:55:49 +00:00
aiongg 1bf99d9866 Merge PR #7966: Slice Select gets its own icon: the Slice knife with a pointer (#7859) 2026-10-21 21:55:49 +00:00
aiongg db33aa6ad4 Merge PR #7951: Note tool: a selected note shows its author and colour in the options bar (#7940) 2026-10-21 21:55:49 +00:00
aiongg e7964eb78e Merge PR #7647: Carry on an open path from its end with the Pen (#6593) 2026-10-21 21:55:49 +00:00
aiongg e135613187 Merge PR #7653: Move typed text with a drag from just outside it (#5956) 2026-10-21 21:55:49 +00:00
aiongg 4c46f0c0fc Merge PR #7928: Fixed Size marquee: Width and Height in any length unit (#7910) 2026-10-21 21:55:49 +00:00
aiongg 149fdb19b6 Merge PR #4762: fix(screen-picker): cancel queued requests without stale replies 2026-10-21 21:55:49 +00:00
aiongg e730ebf07b Merge PR #7385: Content-Aware Fill workspace: an erased sampling area previews as an error, and a failed OK keeps it open (#7384) 2026-10-21 21:55:49 +00:00
aiongg 38f684e4e4 Merge PR #7661: Move tool: a selection lifts up to a pixel's own coverage (#7655) 2026-10-21 21:55:49 +00:00
aiongg d71ff0d34d Merge PR #7580: Preserve saved PCraft previews when manifest replacement fails 2026-10-21 21:55:49 +00:00
aiongg 5c8adfe81d Merge PR #7595: Toolbar: colour chips stay centred when the tools scroll (#7316) 2026-10-21 21:55:49 +00:00
aiongg 574235c98a Merge PR #7640: Open the selection menu on a Quick Selection right-click (#7268) 2026-10-21 21:55:48 +00:00
aiongg 43b1488325 Merge pull request #3975 from abc2cb/codex/fill-mask-preview-20261012
Fill layers: match masks in creation and Pattern Fill preview (part 3/3)
2026-10-21 17:53:41 -04:00
aiongg 34a1089140 Merge pull request #3974 from abc2cb/codex/layers-subrow-scaling-20261012
Layers Panel: Smart Filters mask follows thumbnail size, sub-rows stay compact (part 2/3)
2026-10-21 17:53:29 -04:00
aiongg 04d66caccd Merge pull request #3289 from abc2cb/feat/layers-panel-photoshop-options
Layers Panel: flyout, options and thumbnail sizes (part 1/3)
2026-10-21 17:53:17 -04:00
abc2cbandClaude Opus 5.5 e07d293d5b Mask thumbnail test: expect the key to include the document height
17f149e7 keys mask thumbnails on the document height as well as the
width; main's #2117 test still computed the old width-only key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:29:46 +03:00
denstu473 c82674439f fix(ci): run native Windows tests for format changes
Include the native format crate in Windows platform selection so publication barriers and retry regressions execute in the unit and integration CI legs. Document the durability helpers and regression tests flagged by review.

Validated 20 platform-selection scenarios and all 130 native Windows format tests, plus formatting, Clippy, layering and the format WebAssembly check.
2026-10-21 23:15:43 +02:00
abc2cbandClaude Opus 5.5 17f149e73c Smart Filters: one command builder for OK, mask thumbnails keyed by size
- smart_ui.rs: confirm_command sends every Smart Filter edit through
  smart_filter_dialog::edit_command, the builder the canvas preview
  already uses, so preview and OK cannot build different payloads.
- lib.rs: the mask thumbnail cache revision includes the document height
  as well as the width. Two documents that restore the same layer id and
  width no longer share a thumbnail framed for the other's height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:14:11 +03:00
abc2cbandClaude Opus 5.5 6da0673cd1 Merge upstream/main 6d8cbac7 into the Smart Filters branch
Conflicts:
- filter_dialog.rs: preview_document_for_target keeps our proxy scaling
  and `p` clone; main's Bitmap rule (the preview drops Output resolution,
  #5559) is applied to the same `p`.
- hu/sr/tr/vi catalogs: both sides appended rows at the end; kept both, no
  duplicate keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:14:11 +03:00
abc2cbandClaude Opus 5.5 ff5708361d Merge the Layers Panel flyout branch (upstream/main 6d8cbac7 merged) into the Fill layers branch
One conflict, pattern_cmds.rs: insert_fill_layer keeps the default_mask
argument and takes main's placement(command, params)? signature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:12:09 +03:00
abc2cbandClaude Opus 5.5 ce3fff235e Merge the Layers Panel flyout branch (upstream/main 6d8cbac7 merged) into the Smart Filters mask branch
No conflicts: the flyout branch carries the main merge and its review fixes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:11:00 +03:00
abc2cbandClaude Opus 5.5 743a38e64d Layers panel reorder: Content-Aware Move preview wins, moveTo drops asserted
- canvas.rs: the Content-Aware Move preview now comes before the
  Layers-panel reorder preview. The reorder preview is built from the
  session document and would drop a pending Content-Aware Move result.
- layer_multi_cmds.rs: preview_rejects_invalid_drop_unchanged also runs
  layer.moveTo for each rejected drop: the missing target, `into` on a
  non-group and the invalid position are errors, the self-drop is a
  no-op, and the document stays unchanged each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:09:58 +03:00
Dylan M. Taylor 5933793982 security: prevent script injection in fuzz workflow (SonarCloud S7630)
The Fuzz step interpolated the user-controlled workflow_dispatch input
${{ inputs.fuzz_seconds }} directly into a run: shell command, allowing
script injection by whoever triggers the workflow. Pass it via an env var
(FUZZ_SECONDS) and reference "$FUZZ_SECONDS" so the value is treated as
data, not shell. Resolves SonarCloud githubactions:S7630 (BLOCKER).
2026-10-21 17:08:18 -04:00
bommerts d1c30954aa Fix compound layer property exports for Photoshop actions 2026-10-21 17:08:17 -04:00
Tom InglingandClaude Opus 5.5 052c1c3ab7 Layers: per-layer XMP metadata, as Photoshop's layer.xmpMetadata
A layer keeps its own XMP packet (Layer::xmp), where scripts and embedding
apps store data about the layer under their own namespaces.

- PSD/PSB/layered TIFF: read from and written to shmd > cust > layerXMP
  (UTF-16, NUL-terminated, after layerTime), laid out as Photoshop 2026
  writes it; an unchanged Photoshop block round-trips byte for byte.
- .pcraft: optional `xmp` on each layer.
- layer.xmpMetadata {layer?, rawData?}: read, set (one undo step) or
  remove with null; refuses malformed XML, DTDs, NUL and > 4 MiB.
- Duplicate Layer copies the packet; document.inspect reports hasXmp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:03:02 -04:00
abc2cbandClaude Opus 5.5 8cc64b86a1 Merge upstream/main 6d8cbac7 into the Layers Panel flyout branch
Conflicts:
- commands.rs: layer.duplicate keeps the "Add copy" option; main's #5880
  dropped the second damage pass (`before` and note_insert) for single
  duplicates, so the clone of the document goes away too.
- jobs.rs: the success arm runs main's relayout damage first, then our
  Expand New Effects disclosure update.
- panels.rs: the Background no longer loads a transparency selection on a
  thumbnail click (main), and a thumbnail size of None has no thumbnail to
  click (ours); main's `pos` / `on_thumb` now come before layer.select, so
  the size guard moved there.
- hu/sr/tr/vi catalogs: both sides appended rows at the end; kept both, no
  duplicate keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 23:57:52 +03:00
David Mann dd695a7cfb Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 22:54:34 +02:00
David MannandClaude Opus 5.5 8097063c46 Address CodeRabbit review: shortcut sets leave action alternate keys to the action
A set built from the bindings dropped action keys from the main shortcuts but copied them from
the alternates, and applying a set replaced the alternates wholesale, dropping an action's
alternate key. Both now treat alternates like the main shortcuts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:54:34 +02:00
David Mann ef361309c8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 22:49:29 +02:00
David Mann 15943f5126 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 22:49:00 +02:00
David Mann 46fe7f6a69 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 22:48:29 +02:00
David Mann 925cb86c4b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	xtask/src/corpus.rs
#	xtask/src/corpus_pins.rs
#	xtask/src/main.rs
2026-10-21 22:40:54 +02:00
bommerts e2a238737d Scale shape strokes when resampling documents (#8341) 2026-10-21 16:15:22 -04:00
JGF e481bbe32a New Document: labels show the arrow, not a text cursor (#8331)
In File > New, the field labels and headings (Width, Height, Resolution,
Color Mode, Background Contents, PRESET DETAILS, the Saved tab's text)
were selectable egui labels, so hovering them showed the I-beam as if
they were editable. In Photoshop they are static text.

- The dialog body turns off selectable labels, so every static label
  keeps the arrow. The name field keeps its text cursor and the size
  fields their scrub cursor.
- The preset-save error message stays selectable so it can be copied.
- Test: a kittest hovers the six labels and the empty Saved list
  (arrow) and the name field (text cursor). It fails without the fix.
2026-10-21 15:30:21 -04:00
JGF a310863330 Actions: Photo Filter saves to .atn under the name Photoshop runs (#8301)
Save Actions wrote Photo Filter steps with the four-char event code PhtF.
Photoshop loads the file but stops on playback ("The command 'Photo
Filter' is not currently available"). The same descriptor runs under the
string event photoFilter (measured by the reporter in Photoshop 27.11 at
8 and 16 bits). PhtF is the adjustment's class, not an event.

- Photo Filter now exports by name (photoFilter), like Black & White,
  Box Blur and Surface Blur since dfe94d6a. The descriptor is unchanged.
- Import still accepts PhtF through LEGACY_CODES, so actions PhotoCraft
  saved before keep loading, and they re-save under the name.
- Tests: Photo Filter joins the by-name/legacy-code test (export id,
  import by name and old code, near misses stay Photoshop steps). A new
  test records the issue's exact step, saves it strictly with nothing
  skipped, loads a Photoshop-authored step and an old PhtF step, re-saves
  both as photoFilter, and plays both the same as a direct run at 8 and
  16 bits. Both tests fail without the fix.
2026-10-21 15:30:15 -04:00
David MannandClaude Opus 5.5 a1b56b2d72 Address CodeRabbit review: a layered Duotone writes its real merged image, for #4935
A Duotone PSD opens flattened from its merged image (its layer records are
not read back), but with Maximize Compatibility off a layered Duotone was
saved with the white placeholder, so reopening it showed white. Its merged
image is now always written, like a document without layer records.

Test: a_layered_duotone_keeps_its_image_without_maximize_compatibility.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:04:36 +02:00
David MannandClaude Opus 5.5 45c7b57b6a Address CodeRabbit review: render smart filters with unmodeled settings stay verbatim, for #6160
Clouds, Difference Clouds, Fibers and Lens Flare opened as editable smart
filters even when their descriptor held an extra or repeated setting, a
value of another type (a long centre, a unit seed), an RGBC colour with
extra keys or out of 0-255, or a Lens Flare enum of another type. A
re-save rebuilt the item without it. They now need exactly the keys and
value types Photoshop writes; anything else stays a psd.unsupportedFilter
and round-trips byte for byte, as main's newer filters do with only_keys.

Test: render_filters_with_settings_we_dont_model_stay_verbatim (30 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:04:36 +02:00
UnemarsandClaude Opus 5.5 c3e333cb33 Camera Raw detail preview: write the retoned area back in bands
The raw-curve path kept a second whole-area f32 buffer next to the retoned
pixels while converting them back to the surface format; it is now one
band-sized buffer, read from the source per band to keep the stored alpha.
The develop doc says what retone documents: close to Open, differing only in
the deepest shadows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:44:57 +02:00
UnemarsandClaude Opus 5.5 c695493153 Control protocol: the open-time Camera Raw dialog's demosaic choice
openingRaw is {name, path, demosaic}; ui.demosaic is accepted only while a raw
opens; rawDefaults.demosaic is the method the app's raw opens develop with; Open
re-develops when the chosen method differs from the one the open used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:44:44 +02:00
Ian Swope 0edfe05f2c Compose: effect maps that don't fit in the effect cache are built over the render rect, not rebuilt whole on every render (#8330)
A render needs every layer's effect maps. On a large document whose
ordinary layers' whole-region maps take more than the effect cache budget
together, building one evicted the next one needed, so every render, of
any size, built them all again: 1.8-4.4 s per render on a 9000x6000
document with three full drop-shadowed layers. When they don't fit, local
maps are now built over the render rect padded by twice the effects'
reach, as a shapeless layer's already are (#1909), which gives the same
pixels there.
2026-10-21 13:42:06 -05:00
vangel76 d614b67ad5 Regenerate contour-library scorecard after merging main 2026-10-21 20:34:12 +02:00
vangel76 c1810b2c1d Preserve contour presets on malformed input and discard naming drafts 2026-10-21 20:27:53 +02:00
Ufuk Efe f0519b9940 Merge branch 'upstream/main' into fix/tr-corrections (resolve tr.tsv conflict) 2026-10-21 21:22:22 +03:00
UnemarsandClaude Opus 5.5 09b42be36a Merge origin/main into camera-raw-fullres-raw-preview
The open-time dialog's raw curve is shared (Arc) by the proxy worker from main
(#5964) and the full-resolution preview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:18:42 +02:00
UnemarsandClaude Opus 5.5 eca10449ce Merge origin/main into raw-demosaic-choice
docs/scorecard.md regenerated with cargo xtask scorecard (24 of 172); the
Preferences test keeps main's Enhanced Controls rows and the demosaic row;
catalog tails keep both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:18:37 +02:00
aiongg d5b6da319e Merge PR #7924: Smart Blur: 3.5× faster at radius 12 with identical output (#7921) 2026-10-21 18:15:12 +00:00
aiongg f3dbc0fcfb Merge PR #7839: Copy: text selected in an error notice is copied, not the layer (#7807) 2026-10-21 18:15:12 +00:00
aiongg 66e54580b7 Merge PR #7838: Free Transform: a result may take what Memory Usage allows, not 2^30 px (#7807) 2026-10-21 18:15:11 +00:00
aiongg 8aca796773 Merge PR #7821: feat(ui): search font input field sticky on scroll (#7792) 2026-10-21 18:15:11 +00:00
aiongg d5bf6f0398 Merge PR #7846: Scale layer-effect noise by coverage so it follows the glow 2026-10-21 18:15:10 +00:00
aiongg 0c965366d5 Merge PR #7902: Slice Select: the selected slice's handles resize it (#7863) 2026-10-21 18:15:10 +00:00
aiongg 9e55e81677 Merge PR #7898: Lock Slices: Slice Select can't select a locked slice, and both slice tools say why (#7865) 2026-10-21 18:15:09 +00:00
aiongg 02eeecd59f Merge PR #7870: Shortcuts: Ctrl+F4 closes the document beside Ctrl+W (Windows/Linux) 2026-10-21 18:15:08 +00:00
aiongg f6ed54e83e Merge PR #7389: fix(atn): a preset-only Photo Filter step exports its resolved colour (#7235) 2026-10-21 18:15:08 +00:00
aiongg 54229918c9 Merge PR #7271: macOS: keep the bundle's Dock icon after the window opens 2026-10-21 18:15:07 +00:00
aiongg be2760a7bc Merge PR #7353: test(engine): make the define-brush mask allocation fail on its own (fail-after-one) (#212) 2026-10-21 18:15:07 +00:00
aiongg f471a08224 Merge PR #7397: Auto-Blend Layers: turn the Background into a layer before masking it 2026-10-21 18:15:05 +00:00
aiongg 0001f4d0a3 Merge PR #7572: Menu bar: Alt held through a shortcut no longer takes the keyboard, so Enter reaches the dialog (#7481) 2026-10-21 18:15:04 +00:00
aiongg 8e84c55296 Merge PR #7576: Presets: an Export As preset survives a damaged sibling set (test for #6906) 2026-10-21 18:15:04 +00:00
aiongg 8e66e5fcdd Merge PR #7587: Actions: stop when a Save step's document is closed by a Save Document script (#7367) 2026-10-21 18:15:03 +00:00
aiongg 418c70acb2 Merge PR #7617: Studio: the floating Properties card has the Adjustments tab (#7265) 2026-10-21 18:15:03 +00:00
aiongg a6702058db Merge PR #7627: Actions: Minimum / Maximum with Preserve: Roundness is no longer exported as Squareness (#7233) 2026-10-21 18:15:02 +00:00
aiongg 94d28535d3 Merge PR #6464: Survive a lost window GPU device: draw nothing, save unsaved work for recovery, close (#3438, #1215) 2026-10-21 18:15:02 +00:00
aiongg ef62950bff Merge PR #7837: Free Transform: a result over the pixel limit says so, not "not enough memory" (#7807) 2026-10-21 18:15:01 +00:00
aiongg 8baf2ef75b Merge PR #7751: Actions: a Selective Color step keeps the values of its scalar form 2026-10-21 18:15:00 +00:00
aiongg c5630b2a5d Merge PR #7636: Show Technique and Range in the glow Layer Style pages (#6698) 2026-10-21 18:15:00 +00:00
aiongg f90f4b164a Merge PR #7769: Lasso: a short straight segment places a vertex instead of closing the outline 2026-10-21 18:14:59 +00:00
aiongg da9de5c07f Merge PR #2324: feat(toolbar): select grouped tools using press-drag-release 2026-10-21 18:14:59 +00:00
aiongg ebd173de80 Merge PR #6907: test(ui): a toolbar click with no prior hover picks the tool (part of #2603) 2026-10-21 18:14:58 +00:00
aiongg 548eeafcee Merge PR #6629: Canvas overlays stay visible while panning (#6592) 2026-10-21 18:14:57 +00:00
aiongg 0c18dbff9a Merge PR #6472: fix(ui): preserve shape stroke settings at zero width 2026-10-21 18:14:57 +00:00
vangel76 e98988c3a7 Merge remote-tracking branch 'upstream/main' into review/pr-4392 2026-10-21 19:56:30 +02:00
David MannandClaude Opus 5.5 30c420761d Merge remote-tracking branch 'origin/main' into integration-batch-5
Conflicts with main's smart-filter and layer-style work, both sides kept:
- smart_map: main's 27 more filters and this batch's Clouds, Difference
  Clouds, Fibers and Lens Flare (45 known); the item colours are written for
  Pointillize, Tiles and the three coloured render filters.
- layer_style: main's new Stroke default (1 px inside black) with this
  batch's Overprint.
- psd_export: main's verbatim unknown adjustments with this batch's unpadded
  Gradient Map check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:50:16 +02:00
Korg1ch 0c1a59495f test(actions): Hue/Saturation writes and reads hue under H in .atn (#8302, #8308) 2026-10-21 20:46:33 +03:00
Korg1ch e3de56c639 fix(actions): Hue/Saturation uses Photoshop's hue key H in .atn files (#8302, #8308)
The Hst2 adjustment wrote and read hue under 'Hue '; Photoshop's key is 'H   ' (stringID hue, as PhotoCraft's PSD Hue/Saturation smart filter already uses). Exported actions failed in Photoshop with a program error, and Photoshop-recorded actions imported without their hue. Export writes 'H   '; import reads it, and still reads 'Hue ' from PhotoCraft's own older files.
2026-10-21 20:26:44 +03:00
xsvm 977f265760 MCP stdio: a line that is not UTF-8 gets a parse error instead of ending the session 2026-10-22 01:23:01 +08:00
Korg1ch c2ddc8045e test(ui): the tab strip's » button stays at the right end however many tabs fit (#8290) 2026-10-21 20:05:44 +03:00
Korg1ch 4ee0acbb20 fix(ui): pin the document tabs' » button to the strip's right end (#8290)
The overflow button sat right after the last tab that fit, leaving a gap of up to a tab's width at the end of the strip. Photoshop keeps it at the strip's right end; so do both the Pro and the Studio tab strips now.
2026-10-21 20:05:44 +03:00
aiongg 6a663c107b Merge PR #6397: PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 16:57:01 +00:00
aiongg 1d6ba33d76 Merge PR #6317: PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:56:50 +00:00
aiongg b4c64e2fd5 Merge PR #6305: PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:56:49 +00:00
aiongg c32ff12f96 Merge PR #6299: PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:56:49 +00:00
Higgs32584andClaude Opus 5.5 f916c98536 Merge upstream/main into revive/4330-jpeg-options
Conflicts only in the parity docs: status lines take main's text with the
JPEG Options entry (FILE-215-19) on top; scorecard takes main's rows with
FILE-215-19 done and File compatibility 10 / 12 / 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:56:32 -04:00
aiongg 17ad0d74b7 Merge PR #6297: PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 16:55:56 +00:00
aiongg 4570ad3333 Merge PR #6213: Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212) 2026-10-21 16:55:43 +00:00
aiongg dccd9f23e1 Merge PR #6211: Zoom tool: hold to zoom continuously with Animated Zoom on (#6210) 2026-10-21 16:55:42 +00:00
aiongg f1ce0770aa Merge PR #6206: Performance: a cancelled Gaussian Blur job stops within budget (#6205) 2026-10-21 16:55:42 +00:00
aiongg c653757ad5 Merge PR #5847: Shapes: Density and Feather on shape layers (#5846) 2026-10-21 16:55:41 +00:00
aiongg a0acbec162 Merge PR #5109: Tests: generated large-file fixtures and a P20 number (#5107) 2026-10-21 16:55:41 +00:00
aiongg 479eeeae19 Merge PR #3516: scorecard: five rows catch up with work already on main 2026-10-21 16:55:40 +00:00
bazmarth 4573037531 Merge remote-tracking branch 'origin/main' into feat/polygon-corner-radius
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
#	scorecard/tools.toml
2026-10-21 16:49:42 +00:00
Gustavo Ziger 89f8eed896 Merge main into feat/zoom-animation-566 2026-10-21 13:48:26 -03:00
Bob Sabiston 5f13491411 Fix Fixed Size marquee positioning while dragging
Position a fixed-size marquee at the current pointer throughout creation, including when crossing the initial press. Preserve Alt centering and completed-selection movement. Add real-canvas regression coverage at normal and atlas zoom levels.

Builds on itsF4LCON’s exact-size and single-click correction from #6026, preserved as a separate preceding commit.
2026-10-21 11:43:15 -05:00
Higgs32584andClaude Opus 5.5 27466a8e58 CI: rerun the Windows integration job (panic_hunt timing flake, unrelated to this PR)
The previous head ba7e4e9e passed every check; the branch sync to cc479f60 changed
nothing this PR touches, and the failing engine test timed out at its 4 s deadline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:41:59 -04:00
Higgs32584andClaude Opus 5.5 61d12fb05d Merge upstream/main into fix/5703-pen-make-selection
Conflicts in the ko, zh-Hans and zh-Hant catalogs: keep both this PR's
Make Selection row and main's Bicubic rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:41:04 -04:00
Higgs32584andClaude Opus 5.5 d0bff02d17 Merge upstream main into fix/5863-cjk-menu-mnemonics
Keeps both the CJK access-key test (#5863) and main's new
a_command_shortcut_leaves_the_bar_and_runs test in menu_keys_tests.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:40:12 -04:00
itsF4LCON 4f3de93852 Marquee: Fixed Size selects exactly the entered width and height (#6015)
A press between pixels snapped both edges outwards, so 10 x 10 selected
11 x 11 and 1 x 1 selected 2 x 2. The near edge now goes to the closest
pixel edge and the far one is the size away. A click without a drag
places the Fixed Size selection, as in Photoshop.
2026-10-21 11:38:01 -05:00
Korg1ch a6b950c4fc test(actions): Export As is recorded and replayed; a bad recorded step is an error (#7207) 2026-10-21 19:29:16 +03:00
Korg1ch d93458b72b fix(actions): record File › Export › Export As and replay it with its destination and settings (#7207) 2026-10-21 19:29:16 +03:00
David Mann f4d54906eb Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 18:20:56 +02:00
David MannandClaude Opus 5.5 e74f4758d8 Merge origin/main into feat/file-215-2-mezzotint-halftone
Mezzotint and Color Halftone use main's only_keys (now also rejecting a
repeated setting) and enum_from (the Mezzotint table becomes string
pairs) instead of the branch's own only_keys and typed_enum; the new
malformed cases use main's pixelate_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:19:55 +02:00
David MannandClaude Opus 5.5 e57452d945 Merge origin/main into feat/file-215-2-other
Offset and Custom use main's only_keys (now also rejecting a repeated
setting, for every filter that uses it) and enum_from instead of the
branch's own only_keys and typed_enum.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:15:33 +02:00
IvanandClaude Opus 5.5 8d6cc319b8 test(cli): pin --params order against positional pairing (#7071)
run_pairs_params_with_the_preceding_cmd counted the named layers, which
a naive zip of params[i] to cmd[i] also passes. Assert which layer
carries each name, so only pairing each --params with the --cmd before
it passes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-21 19:08:51 +03:00
IvanandClaude Sonnet 5.5 2e119be390 test(cli): tighten help and --cmd/--params pinning (#7071)
`commands --help` also exits 0 with output and empty stderr when it
runs, so the help test could not tell help from execution; assert the
command list (`file.new`) is absent from help output.

Pin that a `--cmd` without `--params` gets its own default: `First` and
`Third` must each appear exactly once, so a neighbour's params leaking
onto the middle command fails. Also assert exit 0 in the reversed-order
repeated-flag case and explain the layer count.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:08:51 +03:00
IvanandClaude Sonnet 5.5 2ab2de8efa test(cli): pin argument-parsing behaviour before the clap port (#7071)
Add in-process tests for the current hand-written parser so the module
split and the clap port can prove they keep behaviour: top-level
help/version/no-args dispatch, `<sub> --help`/`-h` for all eight
subcommands (returns on stdout, nothing runs), unknown flags on every
subcommand (exit 2, flag named on stderr), `--flag=value`, bare flag
given a value, value flag missing its value, last-wins for repeated
value flags, and `run` pairing each `--params` with its preceding
`--cmd`.

Assertions use exit codes, which stream got output and the offending
flag name, not full help or error wording, so they survive clap's
different messages.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-21 19:08:41 +03:00
David Mann 27581e4d87 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/gaps.md
2026-10-21 18:07:44 +02:00
David Mann 7021fa7a7c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-21 18:06:39 +02:00
steinluizandClaude Opus 5.5 407cd229a4 fix(ui): Free Transform corners show the diagonal resize arrow on wide or tall boxes
A corner's arrow was aimed from the opposite corner, so a box wider than
about 2.4:1 (or taller) showed the horizontal (or vertical) arrow at its
corners. Corners now aim along their bisector as the view shows it, so an
upright box's corners always show the diagonal arrows, as in Photoshop.
The same fix applies to the type box and Content-Aware Move's box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:05:54 +02:00
David Mann 61fb0efaef Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 18:04:10 +02:00
David Mann 8cc639d9b6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 18:02:50 +02:00
David Mann c27f1670d0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 18:00:21 +02:00
David Mann 38ca7288a5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 17:58:45 +02:00
David MannandClaude Opus 5.5 68d8130b77 Use main's enum_from and unit_in for the blur smart filters
The blur tables are now (Photoshop value, ours) string pairs, so the typed
enum check goes through main's shared enum_from instead of a second reader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:55:47 +02:00
aiongg 773ccdf25a Merge PR #7771: Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) 2026-10-21 15:55:27 +00:00
aiongg 879ad9d149 Merge PR #7733: Painting symmetry: canvas edits move the axis with the pixels (#7732) 2026-10-21 15:55:26 +00:00
aiongg 65f7150ccd Merge PR #6681: Content-Aware Move: live preview for large selections (TOOL-213-4, #6680) 2026-10-21 15:55:26 +00:00
aiongg 26df98e878 Merge PR #5889: Image: Bitmap dialog with Output resolution and per-method settings (#5559) 2026-10-21 15:55:25 +00:00
David Mann 8c97f411ba Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 17:54:25 +02:00
David Mann 4d837a9269 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-10-21 17:53:36 +02:00
David Mann 6848c92e28 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:52:03 +02:00
David Mann f784980d34 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:50:34 +02:00
Rene Leonhardt aa715535f3 perf(format): reuse save cache ownership safely
Move cached keys and compressed buffers between saves, and compare weak
allocation pointers without temporary strong-reference updates. Keep
payload budgets and archive bytes unchanged.

Allow fallible tile edits after a cached save. Move pixel storage when
only weak references remain; use a fallible copy for shared storage and
restore the original tile if the copy fails.

Cover cache key reuse, edits after saves at every depth, and weak/shared
tile ownership. Name the fixed revisions behind the existing timings.
2026-10-21 17:49:09 +02:00
David Mann 4cce737673 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:48:32 +02:00
David Mann 23d564165f Merge remote-tracking branch 'origin/main' into HEAD 2026-10-21 17:48:11 +02:00
aiongg a7825b9e0f Integration: regenerate docs/scorecard.md for the batch 2026-10-21 15:47:34 +00:00
aiongg 556931ad90 Merge PR #6397: PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 15:47:12 +00:00
aiongg 29c073b75c Integration: Mezzotint and Color Halftone use main's only_keys and enum_name, and the new test cases use pixelate_item; the PR's helper copy dropped, for #6317 2026-10-21 15:47:04 +00:00
aiongg a936a93464 Merge PR #6317: PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 15:46:55 +00:00
aiongg 0034c989e6 Integration: Offset and Custom use main's only_keys, which also rejects a repeated key, and enum_from; the PR's only_keys and typed_enum dropped, for #6305 2026-10-21 15:46:46 +00:00
aiongg 9bf386adc9 Merge PR #6305: PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 15:46:25 +00:00
aiongg 44a9161960 Integration: Surface Blur's radius read with unit_in, unit_value dropped, for #6299 2026-10-21 15:46:14 +00:00
aiongg 8564c74861 Merge PR #6299: PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298) 2026-10-21 15:46:07 +00:00
aiongg a8a6894ca7 Merge PR #6297: PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
# Conflicts:
#	crates/io/src/smart_map.rs
2026-10-21 15:46:07 +00:00
aiongg e7ddf6d7c7 Merge PR #7771: Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) 2026-10-21 15:45:55 +00:00
aiongg 5baa87b131 Merge PR #7762: Perspective Crop: Color Sampler points move with the image (#7760) 2026-10-21 15:45:54 +00:00
aiongg 3be9e7bcb1 Merge PR #7733: Painting symmetry: canvas edits move the axis with the pixels (#7732) 2026-10-21 15:45:54 +00:00
aiongg e07df7877f Merge PR #6708: Paths: the path selected in the Paths panel is drawn on the canvas (#6550) 2026-10-21 15:45:53 +00:00
aiongg a425efcb2a Merge PR #6681: Content-Aware Move: live preview for large selections (TOOL-213-4, #6680)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:53 +00:00
aiongg 57aef126b0 Merge PR #6213: Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:52 +00:00
aiongg aa1a1ea5d4 Merge PR #6211: Zoom tool: hold to zoom continuously with Animated Zoom on (#6210)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:51 +00:00
aiongg 19b96d5351 Merge PR #6208: Preferences: Save in Background off saves in the foreground (#6207)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:51 +00:00
aiongg 066aae7233 Merge PR #6206: Performance: a cancelled Gaussian Blur job stops within budget (#6205)
# Conflicts:
#	docs/gaps.md
2026-10-21 15:45:46 +00:00
aiongg f9587384c7 Merge PR #5889: Image: Bitmap dialog with Output resolution and per-method settings (#5559) 2026-10-21 15:45:39 +00:00
aiongg e027e048fc Merge PR #5847: Shapes: Density and Feather on shape layers (#5846)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:38 +00:00
aiongg 51d26f36bc Merge PR #5840: Export As: Resample menu, incl. Nearest Neighbor (#5838)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:38 +00:00
aiongg 7ad35873b8 Merge PR #5109: Tests: generated large-file fixtures and a P20 number (#5107)
# Conflicts:
#	docs/scorecard.md
2026-10-21 15:45:37 +00:00
aiongg 86eb4e6739 Merge PR #3516: scorecard: five rows catch up with work already on main
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-21 15:45:33 +00:00
aiongg cbc091dd68 Integration: .act colour tables in the parity doc's presets table, for #5407 2026-10-21 15:45:17 +00:00
aiongg 77557d75f1 Merge PR #8229: Integration batch: 8 PRs on main
# Conflicts:
#	crates/engine/src/layer_style.rs
#	crates/io/src/psd_export.rs
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
2026-10-21 15:45:07 +00:00
Michael Higgins 616ab9d56a Merge branch 'storytold:main' into fix/6745-atn-event-mapping 2026-10-21 11:37:25 -04:00
Gert Pellin cdf9f9b785 test(ui): cover the Perspective Warp plane in the guide-order test (#7781)
The Perspective Warp session started with no planes, so a plane drawn
before the guide passed the test. Drag out one plane across the guide
and check its outline draws over the guide, like the Puppet Warp mesh
and the Vanishing Point plane.
2026-10-21 17:31:51 +02:00
Korg1ch 01784f104f test(raw): a developed raw takes the Raw Defaults resolution on every open path; other files keep theirs (#7789) 2026-10-21 18:28:11 +03:00
Korg1ch 699bbd4b85 fix(engine): a developed raw opens at Preferences › Raw Defaults › Resolution (#7789) 2026-10-21 18:28:11 +03:00
Korg1ch 3d3b67b74c test(ui-egui): light themes outline the slider knob at 3:1 (#7927) 2026-10-21 18:28:10 +03:00
Korg1ch 76e4e81e33 fix(ui-egui): outline the slider knob in the light themes (#7927) 2026-10-21 18:28:10 +03:00
Korg1ch bf47df8907 test(ui-egui): Save a Copy suggests the document's own name with the preference on (#8080) 2026-10-21 18:28:09 +03:00
Korg1ch 5f4c636b09 fix(ui-egui): File Handling preference to not append "copy" when saving a copy, as in Photoshop (#8080) 2026-10-21 18:28:09 +03:00
Korg1ch 66ff3bf7eb test(engine): a Displace smart filter keeps its embedded map; an outside new map is refused before it is read (#7423) 2026-10-21 18:26:00 +03:00
Korg1ch d13facdc28 test(ui): slice handles resize their edges; the middle still moves; locked slices keep their size (#7863) 2026-10-21 18:25:43 +03:00
Korg1ch dcfac95286 test(ui): Slice Select and Slice refuse presses while slices are locked (#7865) 2026-10-21 18:25:36 +03:00
Korg1ch 06d55f7adb fix(ui): locked slices can't be selected with Slice Select, and both slice tools say why (#7865) 2026-10-21 18:25:35 +03:00
Mani Arasteh 3e44821c63 test(ui): cover tool memory persistence 2026-10-21 18:55:31 +03:30
Michael Higgins d0ceea9646 Merge branch 'storytold:main' into fix/7885-warp-surface-drag 2026-10-21 11:24:30 -04:00
Michael Higgins 837ad374ed Merge branch 'storytold:main' into fix/8049-alt-wheel-zoom 2026-10-21 11:24:17 -04:00
Michael Higgins 9344d466e4 Merge branch 'storytold:main' into fix/7582-clipboard-crash-guard 2026-10-21 11:24:06 -04:00
Michael Higgins 386f8941e7 Merge branch 'storytold:main' into fix/7568-cpu-canvas-crisp-100 2026-10-21 11:23:57 -04:00
Korg1ch 3c7ee9eded fix(engine): Displace on a Smart Object embeds its map in the smart filter, as Photoshop does (#7423) 2026-10-21 18:23:45 +03:00
Korg1ch 019eb273cc fix(ui): Slice Select's handles resize the selected slice (#7863) 2026-10-21 18:23:27 +03:00
Michael Higgins 7e7defd0fb Merge branch 'storytold:main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-21 11:23:13 -04:00
Michael Higgins 968d8db4e1 Merge branch 'storytold:main' into fix/7524-plugin-preview-px-scale 2026-10-21 11:22:01 -04:00
Michael Higgins 3ae0678678 Merge branch 'storytold:main' into fix/5745-auto-blend 2026-10-21 11:21:07 -04:00
David MannandClaude Opus 5.5 83b3087914 Keyboard Shortcuts: the dialog switches sets through the engine too (#8286)
The dialog's Set dropdown replaced its bindings with the set's copy and
computed "(modified)" itself, so it still dropped the actions' F-keys and
marked a set modified when one was assigned. It now uses the engine's
ShortcutSet::apply (what useSet does), shortcut_sets::differs (what the
engine's `modified` uses) and ShortcutSet::from_bindings for Save and
New Set. Sets sent with `sets` (the dialog's OK) hold no action keys
either, and an action key a set saved earlier still holds doesn't count
as a change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:20:55 +02:00
Branch Sync 48d2fb45bd Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:54 -04:00
Branch Sync 133ea2b1ec Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:46 -04:00
Branch Sync 25634c1794 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:33 -04:00
Branch Sync 960d927a37 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:25 -04:00
Branch Sync d08a16507c Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:17 -04:00
Branch Sync 24970b1622 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:18:07 -04:00
Branch Sync f5161d9cb1 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:49 -04:00
Branch Sync 1069d30cb1 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:31 -04:00
Branch Sync 0750751261 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:17 -04:00
Branch Sync d5d937ac3e Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:17:09 -04:00
Branch Sync be9cca9bd9 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:16:55 -04:00
Branch Sync a81d472f5a Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:16:39 -04:00
Branch Sync cc479f604a Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:16:15 -04:00
Branch Sync f343abda5d Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:15:43 -04:00
Branch Sync 1d3e94aa4c Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:15:37 -04:00
Branch Sync 643c9b7044 Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD 2026-10-21 11:15:30 -04:00
Michael Higgins da307f8f17 Merge branch 'storytold:main' into fix/auto-blend-background-mask 2026-10-21 11:09:47 -04:00
fjamespriceandClaude Opus 5.5 01b57fe992 fix(camera-raw): a control-channel render also clears a recovered worker failure
The status bar's report of a failed worker develop was cleared only when the
dialog drew, so after a control-channel edit rendered a newer frame,
statusError still reported the old failure until the next frame. The check is
now a method that menu_update also calls after its synchronous render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:09:45 -04:00
Reynold Hu b2611ce846 docs: list the canvas-size page in troubleshooting 2026-10-21 22:28:07 +08:00
Reynold Hu 8d7b5324ef docs: answer the Canvas Size white border question (support) 2026-10-21 22:28:07 +08:00
Aditya VishwakarmaandClaude Opus 5.5 4c0e2d8613 contributors: add adi-1108
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:55:41 +05:30
nathanZWandClaude Opus 5.5 25fa543b19 fix(ui): drop the inner outline on resizable dialogs (#8199)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:14:54 +02:00
Aditya VishwakarmaandClaude Opus 5.5 b08c7a3275 test(algo): cover Custom convolution bounds and depth semantics
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:07:46 +05:30
FartmanAndBurps a0c6af075c docs(print): explain interactive positioning coordinates and safeguards 2026-10-21 14:36:49 +01:00
Aditya VishwakarmaandClaude Opus 5.5 1f4ad6041c test(algo): cover deinterlace choices and NTSC signal limits
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:57:32 +05:30
Aditya VishwakarmaandClaude Opus 5.5 ef2d8b1a7d docs(architecture): requirements, not pins; Cargo.lock has the exact versions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:54:16 +05:30
Aditya VishwakarmaandClaude Opus 5.5 ff8dc65c82 test(io): cover PSD palette boundaries and bitmap row packing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:52:05 +05:30
Aditya VishwakarmaandClaude Opus 5.5 3e014505ad test(codecs): cover TGA packet boundaries
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:51:28 +05:30
Aditya VishwakarmaandClaude Opus 5.5 c8ffb9a8b0 docs(architecture): drop references to the removed rust-framework-options.md
The guide pointed to rust-framework-options.md, which is no longer in the
repo, for version pinning and the UI toolkit choice. Say where versions are
pinned, and drop the two open decisions this document already records as
made (egui/eframe on wgpu; the WebAssembly plug-in API).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:46:30 +05:30
Aditya VishwakarmaandClaude Opus 5.5 1c2ed13dd3 fix(engine): Remove Background looks for the subject in the layer, not the canvas (#8235)
On a pixel layer that doesn't fill the canvas (a placed photo, or the
canvas grown), Select Subject ran over the whole canvas, so the photo's
rectangle stood out from the empty canvas around it as the subject. The
mask then revealed the rectangle, which a Stroke layer style outlines.
Search and refine the edge within the layer's own pixels instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:13:08 +05:30
Ebenezer-03 5bcf78416d Object Selection: preserve modifiers from mouse-down
Capture Shift and Alt at the initial press so early key release does not turn Add or Subtract into Replace. Cover real canvas clicks and rectangle drags, including a subsequent unmodified click.
2026-10-21 18:11:07 +05:30
Aditya VishwakarmaandClaude Opus 5.5 4e89f04d05 fix(pen): Alt-drag grabs the last anchor's incoming handle too (#6077)
While drawing, the Pen shows both handles of the last anchor, but an
Alt-press only grabbed the outgoing one; on the incoming handle it added
a new anchor instead. Alt now grabs whichever shown handle is under the
pointer and turns it alone, as Photoshop's Alt (Convert Point) does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:08:27 +05:30
jonathanwxh-cellandClaude Opus 5.5 171f612337 Keep Batch results when the action saves and then closes the document
Batch dropped recorded Save / Save As / Save a Copy steps and saved the
active document after the whole action, so "..., Save As, Close" failed on
every file, and a Close that left another document active saved that one
instead. Treat a recorded save as marking the result (Photoshop's "Override
Action 'Save As' Commands"): write the document as the save found it when it
is closed or another document is active at the end, else save it after the
action as before (#3058). Droplets keep the save steps too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:38:27 +08:00
Michael Higginsandcoderabbitai[bot] d781772ed8 Update crates/plugins/src/manifest.rs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-21 08:36:06 -04:00
Higgs32584andClaude Opus 5.5 f776f128de CPU canvas: a large document shows its own pixels at 100% (#7568)
Without a usable GPU the canvas uploads one texture of at most 4096 px per
side (or the device's smaller limit), so a larger document was drawn from a
reduced picture, magnified and bilinear-filtered: blurred at 100%.

When that whole-document texture is coarser than the screen, the view now
also composites just the part in sight at the screen's resolution (one texel
per document pixel at 100% and above, crisp nearest sampling) into a
view-sized texture drawn over the reduced one on its own checkerboard. The
region snaps to a 256-texel grid so small pans reuse it, edits and stroke
steps in view update only their texels, and its cost depends on the window,
not the document. The size decision is the pure `detail_plan`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 08:16:51 -04:00
Higgs32584andClaude Opus 5.5 9f9209c5cc Clipboard: a panic while reading the clipboard is reported, not a crash (refs #7582)
The OS clipboard read (arboard, plus decoding a copied file with
photocraft-codecs) and write now run under crash_guard::guard, like Open
and Export. A panic there becomes the paste's error in the status bar and
the app keeps its documents, instead of closing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 08:15:14 -04:00
Marco Arnulfo f68fc36020 Layer Comps: the in-place rename belongs to its document; the test clicks inside the name 2026-10-21 14:10:31 +02:00
Higgs32584andClaude Opus 5.5 20c9dec174 Plug-ins: an unknown "unit" is unitless, not a manifest error (#7524)
Unknown manifest fields were always ignored, so a plug-in may already
carry any "unit" value; failing its install would break it. Anything but
"px" (an unknown string, a number, null...) now reads as "none", as when
the field is absent. The plug-in loader has no warning channel, so this
stays silent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 08:10:16 -04:00
4decb8dd30 Address CodeRabbit review: .act vs .aco by content, strict corpus walks (#5407, #4496)
- swatches.import with bytes only (no path or format): an .act whose first
  colour is 0, 1, x or 0, 2, x still went to the .aco reader, which ignores
  trailing bytes and returned a handful of junk swatches. At the two .act
  lengths (768/772) an .aco header now counts only when its sections span
  the whole file (new `aco::sections_len`); a Photoshop .aco of exactly
  768 bytes is still an .aco. Test: act_that_starts_like_an_aco_is_still_an_act.
- The .atn corpus tests (psd and engine) skipped unreadable folders and
  entries silently; they now fail with the path.
- act::write doc: the trailer is written when `count` or `transparent` is given.

Co-authored-by: Abdul karim mia <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 14:06:56 +02:00
Higgs32584andClaude Opus 5.5 65f38bf3ed Plug-ins: scale "unit": "px" params on a reduced live preview (#7524)
A filter plug-in's live preview runs on a reduced copy of a large
document, but its params only went through the built-in name list, so a
pixel length with another name (Chromatic Aberration's Amount) previewed
2-4 times too large, and a unitless `size` or `radius` was divided.

number and int params now take an optional "unit" ("px" or "none", the
default; anything else is a manifest error). The preview divides px
params by the reduction (defaults included, ints rounded, kept in range)
and limits the factor so a px param with a positive minimum never drops
below it. Unitless params are never scaled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 08:00:57 -04:00
FartmanAndBurps 755ca9e2a0 feat(print): add interactive preview positioning
Drag the visible image in the print preview using paper-scaled egui points.
Switch Center to manual without moving the grab point, allow signed Top/Left
offsets, and retain the shared preview/PDF layout and mirrored behavior.

Add regression coverage for negative PDF placement, native HiDPI dragging,
centering, clipping, scaling, fit, mirroring and invalid geometry.

Validation: 25 engine print tests, 19 file-dialog unit tests, four print-dialog
integration tests and the adversarial-command test pass. Formatting,
all-target clippy, desktop check/build and layer checks pass.
Three existing socket-binding tests are sandbox-blocked and excluded from
the successful print-suite rerun. GUI capture and wasm checks remain limited
by the environment.
2026-10-21 12:31:08 +01:00
Brian MonksandClaude Opus 5.5 e92c3be28d UI: keep a Control-click's secondary mapping until its release
PointerGone and WindowFocused(false) cleared the mapping mid-gesture. On
macOS a drag that leaves the window still delivers its release, which then
arrived as a primary release, so egui kept the secondary button down. The
mapping now lasts until the release; every press recomputes it, so a missed
release can't remap a later plain click.

Regression test: Control press, pointer exit, focus loss, re-entry and
release leave no button down (failed before this change).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:15:23 +02:00
David MannandClaude Opus 5.5 4bc06b906b Address CodeRabbit review: bound the soft shape frame scan
Look in from each edge through strips that double in thickness, so a subtracted
or intersected subpath's empty margin costs a few renders instead of one per row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:15:11 +02:00
David MannandClaude Opus 5.5 23e9b9ccf8 Address CodeRabbit review: a Zoom hold belongs to its own press and canvas
- A hold left from an earlier press (the tool changed before its release)
  is only reused by the press that started it (same origin and start time);
  a new press starts a fresh one instead of zooming at once about the old
  anchor or never becoming a hold.
- Only a press the canvas owns becomes a hold: not one on a dialog, a
  scrollbar or the rotate compass over the canvas.
- Each canvas (tiled windows, secondary windows) keeps its own hold, so a
  hold on one never zooms another.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:10:59 +02:00
Juan Antonio RedondoandClaude Opus 5.5 9322cc07db fix(adjust): Windows build of the LUT folder test; regenerate the scorecard (#6558)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:08:14 +02:00
David MannandClaude Opus 5.5 87862a6f05 Address CodeRabbit review: a repeated Offset/Custom setting stays verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:03:44 +02:00
Higgs32584andClaude Opus 5.5 b3d09e77a0 Alt + wheel zooms while Alt is held even when the modifier state misses it (#8049)
On Windows the modifier state the wheel events carry could lack a held
Alt until another modifier was tapped, so Alt + wheel scrolled like a
plain wheel (or zoomed, with Zoom with Scroll Wheel on). The Alt key's
own press and release still arrive, and the menu bar already follows
them. The canvas wheel now counts Alt as held from its key press to its
release as well as from the event's modifiers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:02:18 -04:00
David MannandClaude Opus 5.5 6686b22d91 Address CodeRabbit review: a repeated Mezzotint/Color Halftone setting stays verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:00:18 +02:00
David MannandClaude Opus 5.5 645e47e46d Address CodeRabbit review: slice.list lists each stored slice once, with its pieces
slice.list and slice.set report a stored slice with its own canvas-clipped
rectangle (what slice.set takes back) and its visible parts under "pieces";
a slice wholly covered by later ones is still listed, with number null.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:52:35 +02:00
Juan Antonio RedondoandClaude Opus 5.5 8afb30f58b fix(adjust): scan the LUT folder off the UI thread, skip oversized files (#6558)
Review follow-up: the Color Lookup dropdown scans the LUT folder on a
worker thread and shows the last finished scan meanwhile; files over
64 MB (larger than any LUT the parser takes) are not listed; the UI test
uses a tempfile directory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:51:55 +02:00
David Mann 0efeabf7ad Merge remote-tracking branch 'origin/main' into fix/overlapping-slices-split 2026-10-21 12:45:50 +02:00
David MannandClaude Opus 5.5 47a7f8021b Address CodeRabbit review: a rejected Perspective Crop leaves the samplers and their removed count alone (test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:45:09 +02:00
David MannandClaude Opus 5.5 cd407f1a05 Address CodeRabbit review: refuse a Bitmap Output size past 300,000 px
Keeping the print size at a higher Output resolution could ask for more than
the 300,000 px document limit; the side was clamped, silently changing the
print size. Refuse it instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:42:22 +02:00
David MannandClaude Opus 5.5 4010044b7b Merge origin/main into feat/ui-217-4-animated-zoom
docs/scorecard.md: take main's and regenerate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:42:17 +02:00
Reynold Hu 30d5622c51 Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	docs/scorecard.md
2026-10-21 18:41:04 +08:00
David MannandClaude Opus 5.5 87e5d77b0e Address CodeRabbit review: save the brush owner only with its brush
toolBrushes.owner names the tool whose brush lastBrush holds. While the
session brush can't be remembered yet (a sampled tip too big to embed),
lastBrush keeps the previous owner's brush, so keep that owner saved too;
otherwise the next launch gave the old owner's brush to the tool selected
since. Exit flushes the brush before the tool memory, as a frame does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:40:09 +02:00
David Mann dd99f53662 Merge remote-tracking branch 'origin/main' into fix/perspective-crop-samplers 2026-10-21 12:39:41 +02:00
David MannandClaude Opus 5.5 551241d408 Address CodeRabbit review: a turned dual symmetry splits the difference under a non-uniform Image Size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:39:06 +02:00
David Mann 7201189695 Merge remote-tracking branch 'origin/main' into fix/symmetry-follows-canvas 2026-10-21 12:31:29 +02:00
Brian MonksandClaude Opus 5.5 0dcaa98a40 UI: Control-click opens the right-click menus on macOS
macOS delivers Control-click as a primary press with Control held, so no
secondary_clicked()/context_menu() site saw it. ControlClick rewrites it to
the secondary button in raw_input_hook, before panels and tools route
clicks. Control-Option (brush resize, Liquify) and Control-Command keep the
primary button; Control pressed mid-drag still overrides snapping.

Tests: the normalizer's unit tests (now clearing the frame's texture delta),
and a canvas test that feeds a real Control-click through the normalizer
into the app: Marquee and Pen open the tool menu, Move the layer menu, Brush
the Brush Preset picker, and nothing is painted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:28:27 +02:00
David MannandClaude Opus 5.5 60cd4ad099 Address CodeRabbit review: scope the Gaussian Blur speedup claim
The uncancelled speedup is for layers with locked transparency only, the
numbers are local runs and the perf baseline is not re-measured yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:26:07 +02:00
David Mann d073b42290 Merge remote-tracking branch 'origin/main' into perf/p25-blur-cancel 2026-10-21 12:24:58 +02:00
David MannandClaude Opus 5.5 a67b6ed5ca Address CodeRabbit review: narrow two coverage claims, cite evidence as file:line
BUG-207-5: only eight retouch tools are tested to follow the Channels panel target (Healing,
Spot Healing, Sponge and Remove are skipped by changes_gray). BUG-208-4: the Polygonal Lasso test
keeps the selection for Shift or Alt and commits a Shift polygon; no test commits an Alt one.
The notes this branch changes now cite file:line, as docs/development.md asks; ui-parity.md gets
a dated row for the marquee Shift+Alt test instead of rewriting the 2026-10-11 one.
Regenerated docs/scorecard.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:24:49 +02:00
David MannandClaude Opus 5.5 d3a5c28904 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:23:19 +02:00
David MannandClaude Opus 5.5 52640de2b5 Address CodeRabbit review: state each large-file test's memory need
large_file_bench needs about 30 GB (its PSB, as its doc comment says), not 16 GB; the opt-in
tests need about 12 GB (2 GB PSD) and 25 GB (4.4 GB PSB), as their #[ignore] reasons say.
Regenerated docs/scorecard.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:16 +02:00
Juan Antonio RedondoandClaude Opus 5.5 c6943482b5 feat(adjust): Color Lookup lists the LUTs in a Preferences folder (#6558)
Preferences > File Handling > LUT Folder: its .cube, .3dl and .look
files appear in Color Lookup's 3D LUT File dropdown under the built-in
looks, and picking one embeds it like Load 3D LUT... does. Agents can't
set the folder, like the other folder preferences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:20:14 +02:00
David MannandClaude Opus 5.5 c69e6efe07 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:19:59 +02:00
David MannandClaude Opus 5.5 66abf411ad Merge origin/main into feat/ui-217-1-tool-options-persist
docs/scorecard.md: take main's and regenerate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:17:31 +02:00
David MannandClaude Opus 5.5 64534add6d Address CodeRabbit review: keep a faint sparse selection in the coarse preview
The coarse Content-Aware Move preview scaled the selection down in its own
8-bit format, so scattered 1/255 pixels averaged to 0 and the preview failed
with "the selection is empty" while the command moves them. Scale the
selection in 32-bit float instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:15:27 +02:00
David MannandClaude Opus 5.5 375cf82c10 Merge origin/main into feat/tool-213-4-cam-preview-large
docs/scorecard.md: take main's and regenerate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:15:27 +02:00
David Mann d98e03903d Merge remote-tracking branch 'origin/main' into feat/file-215-18-export-resample
# Conflicts:
#	docs/scorecard.md
2026-10-21 12:15:24 +02:00
David MannandClaude Opus 5.5 e5f4f8e260 Address CodeRabbit review: soft shape frame from the hard render, feather claim
A soft shape's transform frame, snap rectangle and Properties X/Y/W/H came from
the path plus the full stroke width on every side, so a centred or inside stroke
grew the frame when Density or Feather was set. The frame is now the pixels the
same shape renders at full Density without Feather (edge strips only, clipped to
the canvas), identical to the hard shape's for every stroke alignment.

feather_matches_photoshops_ramp now enforces the 1-level claim the scorecard
makes (measured max deviation 1 at 8, 16 and 32 bit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:14:46 +02:00
David MannandClaude Opus 5.5 901e19d30b Address CodeRabbit review: budget the Bitmap conversion's working buffers
A higher Output resolution passed the resample budget (document bytes only)
but then allocated ~40 B/px of f32 composite, gray and output buffers at the
new size. Budget the whole working set before resampling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:07:41 +02:00
David MannandClaude Opus 5.5 91a28160ff Address CodeRabbit review: blur smart filters check enum types, units and save ranges
Opening: Smart Blur and Radial Blur enums must have their measured enum
type, Surface Blur's radius must be #Pxl and Smart Blur's radius and
threshold plain doubles; anything else stays verbatim instead of being
re-written in another shape.
Saving: a present value that is not a number or lies outside the range
the filter opens with (Surface Blur radius 0.5, a text Radial Blur
centre) is an error with a warning, not a filter that reopens as
unsupported or a centre silently taken as 0.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:07:18 +02:00
David MannandClaude Opus 5.5 a2b5e4de4f Address CodeRabbit review: an oversized Perspective Warp stays verbatim; parity doc date and PSD row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:07:12 +02:00
David Mann e9b6710b89 Merge remote-tracking branch 'origin/main' into fix/6268-psd-perspective-warp-sf 2026-10-21 11:57:56 +02:00
David MannandClaude Opus 5.5 c2dbd974eb Move the descriptor helpers next to int() so the sibling smart-filter PRs share them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:56:46 +02:00
David MannandClaude Opus 5.5 3c77d1d6aa Address CodeRabbit review: Mezzotint/Color Halftone with an extra setting or a mistyped enum stay verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:55:43 +02:00
tessillustrations-droidandClaude Opus 5.5 3e6af6b5d8 Layers panel: document layer_row and the click test helper
CodeRabbit's docstring-coverage check counted the two functions this PR
touches without a doc comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:55:28 +02:00
David Mann afa8b234ce Merge remote-tracking branch 'origin/main' into feat/file-215-2-blur 2026-10-21 11:55:05 +02:00
David MannandClaude Opus 5.5 19f9f50042 Address CodeRabbit review: keep a filter whose Fltr is not a descriptor verbatim
A recognized no-options filterID (or Shadows/Highlights) with a present
but unreadable Fltr opened as editable and lost the Fltr on save; it now
stays an unsupported filter, written back unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:54:26 +02:00
David MannandClaude Opus 5.5 bac554b181 Merge origin/main into fix/6550-selected-path-outline
Resolve vector_ui.rs: keep main's shows_anchors and the PR's
selected_saved_path; the selected saved path now shows its anchors with the
same tools as the work path (Path Selection and the Pen tools).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:49:56 +02:00
Marco Arnulfo e9f9406f56 Layer Comps: double-clicking a comp's name renames it in place (fixes #6496) 2026-10-21 11:41:22 +02:00
Marco Arnulfo 562601eb47 Save for Web: Width, Height, Percent and Resample in Image Size, linked in proportion (#6388) 2026-10-21 11:40:38 +02:00
David Mann 5abe294af7 Merge remote-tracking branch 'origin/main' into fix/5559-bitmap-dialog 2026-10-21 11:40:10 +02:00
David Mann 2ed222416d Merge remote-tracking branch 'origin/main' into feat/file-215-2-mezzotint-halftone 2026-10-21 11:38:50 +02:00
David MannandClaude Opus 5.5 9164d2eebc Address CodeRabbit review: Offset/Custom with an extra setting or a mistyped enum stay verbatim
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:37:31 +02:00
David Mann b68b64ccb5 Merge PR #6160: feat(psd): editable Render smart filters (Clouds, Difference Clouds, Fibers, Lens Flare)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 11:37:14 +02:00
David Mann 86811ddeee Merge PR #6002: Stroke: keep Overprint (overprint)
# Conflicts:
#	crates/engine/src/layer_style.rs
#	crates/ui-egui/src/layer_style.rs
#	docs/file-format-parity.md
2026-10-21 11:36:58 +02:00
David Mann 5bf2898f4e Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/scorecard.md
2026-10-21 11:36:04 +02:00
David Mann d276fce259 Merge PR #8081: fix(psd): pad Gradient Map blocks (#8070) 2026-10-21 11:30:21 +02:00
David Mann a492716367 Merge PR #5990: Bevel & Emboss: keep the Gloss Contour's Anti-aliased (antialiasGloss) 2026-10-21 11:29:05 +02:00
David Mann 213eaa260d Merge PR #5407: Support .act color tables end to end (swatches + Color Table dialog) 2026-10-21 11:28:43 +02:00
David Mann b16910427c Merge PR #4935: PSD Duotone: read and write the ink block (names, colours, curves) 2026-10-21 11:28:32 +02:00
David Mann 27b8d133d8 Merge PR #4771: Fix mixed-order layered TIFF type geometry and unsafe text fallback 2026-10-21 11:28:23 +02:00
David Mann 4cfb576ee0 Merge PR #4496: .atn: a pinned corpus of real action sets, with byte-exact and load/save/reload tests (#219) 2026-10-21 11:28:19 +02:00
David Mann e7ff19f283 Merge remote-tracking branch 'origin/main' into feat/ui-217-4-save-in-background
# Conflicts:
#	docs/scorecard.md
2026-10-21 11:25:56 +02:00
David Mann 89dbbc9aca Merge remote-tracking branch 'origin/main' into feat/file-215-2-noparam 2026-10-21 11:25:55 +02:00
David Mann e7d82f219b Merge remote-tracking branch 'origin/main' into feat/file-215-2-other 2026-10-21 11:25:40 +02:00
f5543e85fc Integration: merge main, resolve conflicts (#5407)
main added the GPU adapter strings (#1885) at the end of every
translation table, where this PR adds the Color Table strings. Both
blocks are kept.

Co-authored-by: abdul-karim-mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:23:09 +02:00
Marco Arnulfo e6717c7485 Contributors: add my name for the About window 2026-10-21 10:18:38 +02:00
Nikolai Nyegaard 13809228bd Preferences: list the languages alphabetically by their own name
- English stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Greek, Cyrillic and CJK names follow the Latin ones
- Lang::for_picker holds the order; the Preferences dropdown and the native menu's Language submenu used the registry order, which is the order the languages were added
2026-10-21 09:49:09 +02:00
Higgs32584andClaude Opus 5.5 3b75e59875 Warp: a surface drag commits exactly as previewed, now under test (#7885)
A press inside a warp cell (no anchor or handle) pulls the surface; this
test drags a gradient layer that way, commits, and checks every committed
pixel sits where the preview's warp.map put it, including the grabbed
point at the drop position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 03:46:00 -04:00
Nikolai Nyegaard 7eaca683a7 Preferences: the theme swatch star no longer grows the row on hover
- The favourite badge overhangs the swatch by 4 px; placing it with ui.put allocated that overhang, so the row grew and the swatches after the hovered one re-centred, flickering when the shift moved the swatch out from under the pointer
- Click and hover through ui.interact, the star through icons::paint; neither allocates
2026-10-21 09:12:29 +02:00
jonathnwxh 50acc30111 Return exported byte count from EXR Quick Export 2026-10-21 15:05:33 +08:00
VishnuByrraju a8b7d2bbca docs(parity): Crop rows in ui-parity.md match the code and the scorecard (#6562) 2026-10-21 12:27:12 +05:30
VishnuByrraju 765cef4b49 fix(ui): a unit after a range bound in a params doc no longer resets the dialog range (part of #7598) 2026-10-21 12:26:04 +05:30
VishnuByrraju 81d278eaad fix(paths): the Work Path is drawn only while selected or a path tool is active (#6551) 2026-10-21 12:21:40 +05:30
VishnuByrraju c088250481 fix(actions): .atn export of Image Size with resample none no longer adds a pixel resize (#7189) 2026-10-21 12:15:13 +05:30
Eddi Andree Salazar MatosandClaude Opus 5.5 196238fe95 File names: superscript COM/LPT ports, and _ before the dot in web exports
Review follow-ups:

- Windows also reserves COM¹–COM³ and LPT¹–LPT³ (the ISO 8859-1
  superscript digits), with any extension. `is_windows_device_name`
  takes them too; `sanitize` keeps those characters, so `COM¹` became a
  device file name before.
- Save for Web's `portable_generated_stem` added `_` at the end, so
  `path: nul.v2.png` gave `nul.v2_.html`, still the NUL device, and the
  export failed as an unsafe component. Both it and `sanitize` now share
  `portable_device_name`, which puts `_` before the first dot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:29:31 -05:00
VishnuByrraju d04871b5fd fix(layers): mark adjustment layers kept as imported but not rendered (#6541) 2026-10-21 11:54:42 +05:30
jonathanwxh-cell c5e25bba8f Merge current main and validate document activation replies 2026-10-21 14:12:39 +08:00
Eddi Andree Salazar MatosandClaude Opus 5.5 4062ac3211 Contributors: add my name (AndreeSalazar)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:04:33 -05:00
Eddi Andree Salazar MatosandClaude Opus 5.5 7c0d307293 File names: keep Windows device names (nul, con, com1…) off the disk
File › Package named its folder and document after the document with
`file_cmds::sanitize`, which let Windows device names through. On
Windows 11 a document named `nul` failed to package with "path not
found (os error 3)"; on Windows 10 `con`, `prn`, `aux`, `comN` and
`lptN` are devices too.

`sanitize` now adds `_` before the first dot of a device name
(`nul` -> `nul_`, `com1.v2` -> `com1_.v2`) on every platform, so a
package written anywhere can be copied to Windows. The device check
moves from web_cmds.rs to `file_cmds::is_windows_device_name`, and
web_cmds uses it unchanged.

The new package test fails on Windows without the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:02:05 -05:00
VishnuByrraju c50e0f05ea feat(ui): Brushes tab preview-size slider for grid cells and list rows (#7256)
The Brushes tab only had the Size slider of the current brush, which Photoshop users expect to
resize the palette's thumbnails. A second slider under the search field now scales the grid
cells and list rows (0.5x-2x) and leaves the brush size alone.
2026-10-21 11:26:01 +05:30
VishnuByrraju ddb49fa831 fix(ui): menu titles switch on hover at the very top screen edge (#7702)
With one top-level menu open, a pointer pushed against the top screen edge (maximized window)
lit the neighbouring titles (egui hovers widgets within interact_radius) but did not open them,
because the hit test required the pointer inside the title's rect. The title's column now
extends up to the window's top.
2026-10-21 11:21:10 +05:30
VishnuByrraju 881226c60b fix(swatches): a CMYK swatch keeps its inks as the foreground (#7474)
swatches.use converted a CMYK swatch (e.g. a Pantone .ase colour 0/0/100/0) to sRGB and
forgot the inks, so the Color Picker showed 5/1/92/0 and CMYK documents were painted with
the round-tripped inks. It now records them like a colour picked in CMYK (#4196).
2026-10-21 11:17:33 +05:30
VishnuByrraju a1d235b77f fix(info): Info panel CMYK uses the Color Picker's colour-managed conversion (#8101)
The Info panel and Color Sampler readouts used a naive (1-r-k)/(1-k) formula while the
Color Picker converts through the CMYK profile, so the two never agreed (white-to-black
rows differed by tens of percent). device_cmyk now calls convert::rgb_to_cmyk, inside the
document's CMYK space for readouts.
2026-10-21 11:14:14 +05:30
VishnuByrraju 37a0afa16d fix(ui): Browse… buttons for dialog folder and plug-in path fields (#7942, #7531)
Command dialogs with a `dir` field (Artboards to Files, Layers to Files, Layer Comps to
Files, Package…) get a Browse… button that opens the system folder picker, and Install
Plug-in… gets one that picks the .wasm file. The dialog body flags the clicked field and
dialogs::show asks the file dialog, so the filled value lands in the dialog fields.
2026-10-21 11:10:56 +05:30
VishnuByrraju 1ee1354530 fix(ui): grey out Select and Mask in the options bar without a selection (#7520)
The Select and Mask… buttons of the marquee, lasso and Magic Wand options bars were always
live, though Select > Select and Mask… is disabled until there is a selection or a layer mask.
They now follow the same enabled state.
2026-10-21 11:10:19 +05:30
VishnuByrraju 013863da95 fix(psd): an edited type layer updates the Photoshop-era Txt2 objects too (#6971)
Older Photoshop keeps each type layer's text object at
/DocumentObjects /TextObjects (named keys, run lengths under /Length) and
re-renders from it. type.edit refreshed TySh and the numbered /1 /1 list but
left the old text there, so the layer came back with its old text.

build_txt2_at now refits that entry like edited_object does for the numbered
ones: the model text, one ParagraphRun run per paragraph, every other run
array cut or extended to the new UTF-16 total. Entries are only edited in
place (never grown), a malformed run array leaves the entry as the file has
it, and an unchanged block is still written back byte for byte. A legacy
block holding both lists with a stale older copy is repaired on re-save.
2026-10-21 10:39:00 +05:30
Korg1ch 4919146079 test(ui-egui): the dock's width range follows the window; 700 is no longer cut to 520 (#7896) 2026-10-21 07:52:48 +03:00
Korg1ch c9cd56e165 fix(ui-egui): the dock can be dragged as wide as the window allows, not only 520 points (#7896) 2026-10-21 07:52:48 +03:00
Khawar Zamman Wani 6360a8a3ba Merge remote-tracking branch 'origin/main' into claude/github-issue-7437-2fb28f 2026-10-21 10:07:41 +05:30
Korg1ch c41a569dd8 test(ui-egui): Alt pressed during a drag leaves the menu bar alone in either release order (#8113) 2026-10-21 07:33:58 +03:00
Korg1ch 1e93f2cbb7 fix(ui-egui): Alt held during a drag is no menu-bar tap, so Enter commits the transform (#8113) 2026-10-21 07:33:58 +03:00
coolvitto ec9c3ddc70 Fix tooltip text in scrub_label function
Fixed an issue where the kerning tooltip was not translated.
2026-10-21 13:30:06 +09:00
xsvm f2dce80718 Compose: quantize floors without a libm call
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is a software routine there, called once per sample of the backdrop under every adjustment layer of an 8- or 16-bit document. branchless_floor rounds through 2^23 and steps down where that went up: bit-identical to floor for every f32 (a new test sweeps every 4099th bit pattern and the edges around whole numbers and 2^23), and with no branch the loop vectorizes.
2026-10-21 12:26:49 +08:00
xsvm 420353c5ae Compose: adjustment layers blend back in Normal mode without a per-pixel mode dispatch
An adjustment layer's result was blended back into the backdrop pixel by pixel through blend_rgb, which looks up the HDR flag and dispatches on the mode, three channel calls deep, for every pixel. Normal (nearly every adjustment layer) gives the source exactly, so blend_back skips the dispatch for it, and walks the backdrop, the result and the coverage in step instead of indexing by coordinates. Bit-identical.
2026-10-21 12:26:47 +08:00
jonathnwxh 071ab42c70 Document EXR in Quick Export command parameters 2026-10-21 12:03:09 +08:00
jonathnwxh bdd7595b02 Label EXR consistently in translated export preferences 2026-10-21 11:55:05 +08:00
xsvm d278a85696 Compose: tone and hue-range lookups truncate instead of calling floor
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is an out-of-line libm call there. lut, which every Levels, Curves, Hue/Saturation range and modern Brightness/Contrast sample goes through, floored its non-negative table position. Truncating is the same there (NaN casts to 0 either way) and inlines. Bit-identical.
2026-10-21 11:46:27 +08:00
NetterSilverandClaude Haiku 5.5 010c542be4 Liquify: keep short strokes whole so undo removes only the last
Fixes #8055

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 03:38:51 +00:00
Gustavo Ziger e41df200b4 Merge main into feat/zoom-animation-566 2026-10-20 23:29:13 -03:00
Korg1ch ee19856487 test(ui-egui): clicking below the layers deselects them; a row still selects (#8100) 2026-10-21 05:28:32 +03:00
Korg1ch 18aa6a199b fix(ui-egui): a click on the empty Layers panel space deselects every layer, as in Photoshop (#8100) 2026-10-21 05:28:31 +03:00
xsvm aab9cdc045 Compose: Color Balance works out each channel's curve once, not per pixel
color_balance_channel recomputed the folded black point, white point and midtone gamma, and four of the five powf calls of the gamma > 1 Hermite join, for every sample. Those depend only on the sliders, so BalanceCurve works them out once per channel and the per-sample step is the clamp and one powf (or the Hermite polynomial). The CPU compositor builds the three curves once per buffer and the GPU plan once per LUT (it sampled color_balance_channel 3 x 4096 times). Results are bit-identical: a new test compares the curve against the per-sample formula it replaced, to the bit, over 123 slider settings, both Preserve Luminosity modes and a 1/1024 ramp.
2026-10-21 10:10:57 +08:00
Korg1ch 3d7d62e349 test(io): cmls entries without enab inherit the last visibility or start visible (#8093) 2026-10-21 05:05:56 +03:00
Korg1ch 385d2a7a01 fix(io): a layer comp entry without enab inherits the previous visibility, as Photoshop reads cmls (#8093) 2026-10-21 05:05:55 +03:00
zuujiscom 32a2faa7c3 Open/Save: refuse file names that aren't valid Unicode instead of rewriting them (#6601) 2026-10-21 07:30:42 +05:30
jonathnwxh 3ad7333a16 Add linear floating-point EXR Quick Export 2026-10-21 09:50:50 +08:00
zuujiscomandClaude Opus 5.5 dc5006e79e Save: a tab whose file another save writes over is detached and marked unsaved (#6597)
When document B was saved (Save As, Save a Copy, a replayed action) onto the
file document A in another tab was bound to, both tabs pointed at that file and
both read as saved, though A's content existed only in memory: closing A lost
it without a prompt, and A's own Save wrote over B's file.

Every completed save and Save a Copy now detaches any other open document bound
to the written file (compared with file_cmds::same_file): it loses the path and
reads as unsaved, and a notice names it. Detaching rather than refusing keeps
replaying a recorded fixed-path Save As working (actions::tests::recorded_save_*).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:16:59 +05:30
Higgs32584andClaude Opus 5.5 2187a1202a Pen options bar Make Selection: use the path the Pen committed to
Make Selection named the committed path "work" or "layer" by itself, which
could differ from where `pen_commit` put it: a vector mask targeted through
the Channels target goes to the mask, and a path drawn with a saved path
selected is added to that path, not the Work Path. It now decides the layer
case with `targeted_vector_mask`, as `pen_commit` and `path_to_selection`
do, and otherwise uses the active path, which the commit selects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:35:31 -04:00
Higgs32584andClaude Opus 5.5 8fa1217bcc Actions: Add Layer Mask maps only the target layer's mask
A `make` of a `Chnl` whose `At` reference has further items (the mask of
a named layer) was mapped to a layer.layerMask command, which acts on the
target layer. Such a step now stays a kept Photoshop step, reported as
unsupported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:33:02 -04:00
Higgs32584andClaude Opus 5.5 df7c4350eb Artboard pasteboard: cut around the layers as they are shown
The canvas draws `display_doc` (a Move drag's moved layers, a transform or
fill preview, ...), but the pasteboard holes for loose layers came from
the committed document, so a loose layer dragged over the pasteboard was
covered at its new position until the move was committed. Cut the holes
from the displayed document instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:32:59 -04:00
Higgs32584andClaude Opus 5.5 aa76d66e20 Auto-Blend: a contained image one cell wide keeps the seam cell
seam_labels gives a new image only the overlap cells strictly on its side
of the seam. A contained image (all overlap) one grid cell wide in a line
has no such cell, so the seam ran through its only cell and it got nothing:
the all-black mask of #5745 again, for a layer the canvas crops to a strip.
Where a contained image's side of a line is empty, it now takes the seam
cell. Both sides are handled; other images are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:29:07 -04:00
Higgs32584andClaude Opus 5.5 213b95e17c Merge upstream main into fix/6528-solo-restore-cache
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:22:35 -04:00
Higgs32584andClaude Opus 5.5 ae51ce8d82 Merge upstream main into fix/7332-open-no-font-wait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:57 -04:00
Higgs32584andClaude Opus 5.5 ec13017dfb Merge upstream main into fix/7367-atn-close-save-target
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:53 -04:00
Higgs32584andClaude Opus 5.5 f5a0587945 Merge upstream main into fix/auto-blend-background-mask
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:48 -04:00
Higgs32584andClaude Opus 5.5 d7a620f286 Merge upstream main into fix/auto-blend-seamless-gain
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:43 -04:00
Higgs32584andClaude Opus 5.5 5b7c52cd8a Merge upstream main into fix/auto-blend-area-past-canvas
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:38 -04:00
Higgs32584andClaude Opus 5.5 8177fe2d45 Merge upstream main into fix/6865-selection-not-dirty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:32 -04:00
Higgs32584andClaude Opus 5.5 576b7cdfcb Merge upstream main into fix/2095-android-menus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:22 -04:00
Higgs32584andClaude Opus 5.5 3bcd357738 Merge upstream main into fix/2603-toolbar-click-without-hover
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:17 -04:00
Higgs32584andClaude Opus 5.5 f44bf80b86 Merge upstream main into fix/6745-atn-event-mapping
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:12 -04:00
Higgs32584andClaude Opus 5.5 b0acb119d6 Merge upstream main into fix/6752-heic-hlg
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:19:06 -04:00
Higgs32584andClaude Opus 5.5 ec51ae55da Merge upstream main into fix/6744-deselect-noop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:54 -04:00
Higgs32584andClaude Opus 5.5 d7d562287b Merge upstream main into fix/5712-artboard-pasteboard-hides-loose-layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:42 -04:00
Higgs32584andClaude Opus 5.5 967fd444ae Merge upstream main into fix/321-new-document-color-profile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:19 -04:00
Higgs32584andClaude Opus 5.5 aff9d27cf9 Merge upstream main into fix/5703-pen-make-selection
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:14 -04:00
Higgs32584andClaude Opus 5.5 2db345cb1d Merge upstream main into fix/5863-cjk-menu-mnemonics
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:10 -04:00
Higgs32584andClaude Opus 5.5 7a712f96df Merge upstream main into fix/5806-displace-asks-for-map
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:18:00 -04:00
Higgs32584andClaude Opus 5.5 dad64cc084 Merge upstream main into fix/5745-auto-blend
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:17:56 -04:00
Higgs32584andClaude Opus 5.5 ba7e4e9e6f Merge upstream main into fix/5727-move-tool-offset-readout
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:17:50 -04:00
Korg1ch 773fc08763 test(i18n): Simplified Chinese counted messages are translated 2026-10-21 04:17:38 +03:00
Korg1ch 576fd9d353 fix(i18n): translate the 14 counted status messages missing from Simplified Chinese 2026-10-21 04:17:37 +03:00
Korg1ch 322c61c765 test(i18n): Korean counted messages are translated 2026-10-21 04:17:32 +03:00
Korg1ch b0171aa9ee fix(i18n): translate the 14 counted status messages missing from Korean 2026-10-21 04:17:32 +03:00
Korg1ch d0b371f92a test(i18n): Polish counted messages use all three plural forms 2026-10-21 04:17:25 +03:00
Korg1ch fc95c055dc fix(i18n): translate the 14 counted status messages missing from Polish 2026-10-21 04:17:24 +03:00
Korg1ch 9461446919 test(i18n): Indonesian counted messages are translated 2026-10-21 04:17:09 +03:00
Korg1ch dc428e4db6 fix(i18n): translate the 14 counted status messages missing from Indonesian 2026-10-21 04:17:09 +03:00
Higgs32584andClaude Opus 5.5 ac1a7443a9 GPU compositor: pass the timer and batch runs in the OOM retry (main changed submit_batch and run_cell)
main added a GPU timer argument to submit_batch and the batch runs to run_cell,
so the OOM submit-and-retry path no longer compiled after the merge. Also add
the admission-evicted bytes to the flush counter with saturating arithmetic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:09:52 -04:00
Higgs32584andClaude Opus 5.5 9c280d4c58 Background redraw: rustfmt the kept-composite cap check
cargo fmt --check failed on the if/else added in bbcc8321; rustfmt
(max_width 160, use_small_heuristics Max) wants it on one line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:09:42 -04:00
Higgs32584 57948f1155 Merge remote-tracking branch 'upstream/main' into fix/6563-layer-texture-oom 2026-10-20 21:09:26 -04:00
yongjip 48fdaf7279 test(liquify): document source footprint fixtures 2026-10-21 10:01:05 +09:00
Higgs32584andClaude Opus 5.5 50a745bfce docs: scorecard rows for FILE-215-19 on top of main's regenerated file
The merge with main took main's generated docs/scorecard.md. It is
brought back in line with scorecard/file_compat.toml (FILE-215-19 done):
File compatibility 8 done / 14 partial / 5 missing, and the FILE-215-19
row with its status and note. Edited by hand to the generator's row
format, since `cargo xtask scorecard` isn't run locally; CI's
`cargo xtask scorecard --check` verifies it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:59:59 -04:00
Higgs32584andClaude Opus 5.5 35d4493c5a Format: order the DDS and JPEG test modules as rustfmt does
The merge with main left `mod jpeg_tests` above `mod dds_tests` in one
group, which `cargo fmt --check` reorders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:58:58 -04:00
Higgs32584andClaude Opus 5.5 66c33840e0 Selections keep a document with a video layer clean
selection_only_change used same_pixels, which turns down every video layer,
so any selection step marked such a document modified. It now compares video
frames (painted and original) by tile pointer as well; same_pixels itself is
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:58:05 -04:00
Higgs32584andClaude Opus 5.5 7cef41bba2 Merge upstream/main into fix/iris-xe-texel-guard
Conflict in apps/photocraft/src/main.rs: main added
`app.perf.gpu_info.set_available(&rs.available_adapters)` where this branch
installs the texel guard; both are kept (set_available first, then
texel_guard::install).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:55:58 -04:00
Higgs32584andClaude Opus 5.5 3c2ef8b893 Auto-Blend: budget the work area past the canvas by its buffers' memory
The pixel-only cap (2^27) still let two RGBA layers past the canvas with
about 11 GiB of working buffers. Count what Auto-Blend holds per pixel
(each layer's pixels, luminance, alpha and weight, plus the merged picture
and depth map) against a 4 GiB budget; over it, keep to the canvas as
before. The per-layer region reads and luminance/alpha buffers now use the
fallible allocation helpers, so running out of memory is an error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:55:10 -04:00
Higgs32584andClaude Opus 5.5 c00045d7d9 Merge upstream/main into fix/7537-windows-arm-dx12
Resolve conflicts with #4's "Auto on Windows leaves Vulkan out when an
Intel adapter is present" (3c9c93ed) and #7813's adapter choice
(`configure` gained `wanted`):

- gpu_startup.rs: `intel_dx12_applied(.., vulkan_left_out)` from main and
  the patch's `dx12_default_applied` become one
  `dx12_default_applied(adapters, chosen, os, vulkan_left_out)` that
  returns the maker's name (Intel or Qualcomm). `vulkan_left_out` counts
  only for Intel, since only an Intel DXGI adapter makes
  `windows_auto_backends` drop Vulkan. `configure` keeps main's `wanted`
  parameter and Vulkan-free instance; the System Info note names the maker.
- Tests: keep both main's Intel instance test and the Qualcomm test,
  updated to the merged signature, plus one assertion that Vulkan being
  left out doesn't make a Qualcomm DX12 pick "the default".
- Module docs and docs/development.md: main's wording, with Qualcomm
  (Windows on ARM) added to the DX12 default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:55:06 -04:00
Higgs32584andClaude Opus 5.5 bbcc832103 Background redraw: keep finished composites within Memory Usage as it is now
A continued redraw reused the cap from when it began, and the finished composite
was stored with it; after Memory Usage dropped (or the document grew) it could be
kept over the current allowance. The cap is taken again when a redraw continues
and when it finishes (once, not per frame).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:54:05 -04:00
Higgs32584andClaude Opus 5.5 1133cb5a40 HEIF: tag an HDR image on unsupported primaries linear sRGB, with a warning
A PQ or HLG image is decoded to linear light and its ICC profile dropped.
When its nclx primaries have no built-in profile (e.g. DCI-P3, code 11), the
document was left untagged, so the linear samples were read as sRGB-encoded.
It is now tagged linear sRGB, which keeps the tones right, and the import
warns that the primaries were assumed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:53:51 -04:00
Higgs32584andClaude Opus 5.5 6dbc8956ad Document presets: check a rejected blank profile leaves preferences unchanged
Move the empty/whitespace/non-string Color Profile cases into the bad-input
table, which asserts the preferences (presets included) and their revision
are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:53:16 -04:00
Higgs32584andClaude Opus 5.5 98b4574545 Automation: deny file.new profiles that resolve the configured working space
`default` and the `working-*` aliases make file.new resolve Color Settings'
working profile, which on the desktop can be a user-configured .icc path.
Treat them like edit.assignProfile does; exact `working` and `none` skip
resolution and stay allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:53:16 -04:00
Higgs32584andClaude Opus 5.5 1b84acd2e3 GPU compositor: on a layer page OOM, submit and wait before the last retry
Pages dropped to make room can still be held by the work recorded since the
last submit, so the immediate retry may fail although the memory is about to
be freed. render now submits, waits for the GPU and runs the cell once more
before sending the refresh to the CPU (encode, which can't submit, still goes
to the CPU). Bytes dropped to admit pages also count towards the
intermediate-submit threshold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:52:51 -04:00
s17sh70x2jfl031ag ba10fa4404 i18n(pt-br): translate 131 runtime labels that still showed in English
Channels panel menu, Blending Options, canvas right-click menus, Color Picker
titles, rasterize prompts, GPU and monitor status, status messages and Liquify
tool tooltips. Each string is passed through tl! at runtime and was already
translated in other catalogs.
2026-10-21 01:52:35 +01:00
Higgs32584andClaude Opus 5.5 d0fd764ee3 Menus: a shown CJK "(K)" suffix always keeps its key
Labels with letters of their own were assigned in row order, so one before a
suffixed label could take the key the suffix shows, leaving that row without it.
Suffixed labels now take their keys first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:52:30 -04:00
Higgs32584andClaude Opus 5.5 cd6e288820 Displace: the map picker's answer displaces the document that asked
If another document was activated while the picker was open, the filter ran on
that one instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:51:42 -04:00
Higgs32584andClaude Opus 5.5 d0d5e31019 Test that Deselect with nothing selected is still rejected outside playback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:50:59 -04:00
Higgs32584andClaude Opus 5.5 18b176c41e Move tool: the release commits the offset the readout and preview cap at
At the lowest zoom of a huge document a drag can exceed the ±10,000,000 px the
ΔX/ΔY readout and the live preview show; layer.translate got the raw distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:50:39 -04:00
Higgs32584andClaude Opus 5.5 6a97837bcb zero_block_probe: pick the adapter by name with adapter=<name>
On a machine with Iris Xe and a discrete GPU the default request can pick
the discrete one, so the probe would not test the affected adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:50:26 -04:00
David MannandClaude Opus 5.5 168701779c CodeRabbit follow-ups for the #8095 batch (#5807, #4816, #7180)
- type.setStyle/edit/create: an unknown `baseline` is a BadParams error and no longer resets
  superscript or subscript to Normal (#5807).
- Indexed/Bitmap PSD save warns whenever flattening loses something, also for a single hidden,
  masked, non-Normal, effect, type, shape or smart object layer, not only for two or more layers
  (#4816).
- brush.presets.importAbr: the apply step keeps only `replace` and `select`, not a second copy of
  a base64 `data` string of up to ~1.3 GiB (#7180).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 02:48:24 +02:00
Florent Siegenthaler 82f6700dfa Merge branch 'main' into fr-complete-catalog 2026-10-21 02:47:37 +02:00
Higgs32584andClaude Opus 5.5 5eac93bf23 Merge upstream/main into revive/4330-jpeg-options
Main moved on (integration batch #8095 and others). Two conflicts:
- docs/gaps.md revision history: both rows kept, newest first.
- docs/scorecard.md (generated): main's version taken. It needs
  `cargo xtask scorecard` again: with FILE-215-19 done, File
  compatibility reads 8 done / 5 missing rather than main's 7 / 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:47:00 -04:00
Higgs32584andClaude Opus 5.5 92d1ff42f9 Merge upstream/main into revive/4330-jpeg-options
Main added DDS saving (#5993): DDS Options, and FormatChoices carrying the
Targa and DDS answers through every save path (this branch's SaveChoices
carried Targa and JPEG). Resolved by keeping main's name and making it
the one struct for all three answers.

- lib.rs: FormatChoices { tga_bits, dds, jpeg } with from_params (strict),
  from_step (lenient JPEG read of a recorded step), apply and record;
  main's separate block is folded in. Both prompts (dds_options and
  jpeg_options) are kept everywhere: save_to asks Targa, then DDS, then
  JPEG Options; save_options_open covers all four. save_in_place takes
  main's (path, choices) signature and records the plain-Save step with
  plain_save_step for JPEG plus the Targa/DDS answers (save_in_place_with
  folded into it). journal_as_plain_save relabels a parked Targa, DDS or
  JPEG prompt and keeps tgaBits/dds*/quality/jpegFormat when dropping the
  path. replay_save reads choices with from_step.
- SAVE_OPTIONS_OPEN names all four prompts; DDS Options uses it too.
- targa_options_ui: main's plain-Save step (no path) through this
  branch's write_answered_save. JPEG Options gets main's Enter-on-a-
  focused-Cancel fix.
- view_cmds Save a Copy asks Targa, DDS, then JPEG Options; write_copy
  takes FormatChoices.
- control.rs: both app.save tests (JPEG and DDS) kept.
- engine file_cmds: SaveOpts keeps jpeg_format and dds; param docs list
  both jpegFormat and ddsCompression/ddsMipmaps. CLI: both --jpeg-format
  and --dds-compression/--dds-mipmaps. codecs re-exports JpegFormat and
  DdsCompression/DdsOptions.
- docs/book: both entries kept (control-protocol app.save lists both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:43:26 -04:00
Higgs32584andClaude Opus 5.5 870c776d73 Merge upstream/main into fix/5703-pen-make-selection
canvas_tool_menu.rs tests: keep both main's #1056 test
(path_selection_tools_open_the_path_menu) and this branch's
pen_options_bar_makes_a_selection_from_the_path. make_selection still
mirrors choose_pen's commit-then-dialog flow, which #1056 left unchanged.

ko/zh-hans/zh-hant.tsv: keep this branch's "Make Selection" row and
main's four clipboard-unavailable rows, with no duplicate keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:39:27 -04:00
404a17e711 Integration: write the .atn defaults Photoshop plays differently, keep resets and both colours (#4496)
The PR stopped writing PhotoCraft's defaults for params a step leaves
out. Checked against Photoshop, that is fine for Fill opacity and mode,
Crop delete, Selective Color method, Channel Mixer monochrome, Paint
Bucket contiguous and Gradient Map reverse/dither, but Photoshop plays
other missing keys differently: Canvas Size anchors top left (PhotoCraft:
centre), Apply Image blends Normal (Multiply), Color Balance turns
preserve luminosity off (on), and a Paint Bucket step without a
tolerance doesn't play at all. Those, and the unverified Hue/Saturation,
Color Balance tone and Save for Web keys, are written again as before.

- `tools.setColors` exported only the foreground of a step that sets
  both colours; such a step now doesn't export (the strict save refuses
  it, as before the PR).
- `kind` on an imported `layer.setAdjustment` made Photoshop's "reset to
  default" (`set` with an empty `T`) a no-op merge; it now joins only
  params that edit something.
- `cargo xtask corpus --atn` (and `--all`) failed on Windows: the Bjango
  archive has a macOS `Icon\r` file tar can't create there; it's skipped.
- `test-corpus --changed` now runs for `atn_cmds` changes, and the
  `ALL` doc comment is back on `ALL`.

Tests: defaults_photoshop_plays_differently_are_written,
set_colors_exports_only_a_single_colour and
adjustment_layer_reset_still_resets fail without this commit; the psd
and engine .atn corpus tests still pass (16 files byte-identical; 513
kept Photoshop steps).

Co-authored-by: Fabio Lissi <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 02:28:58 +02:00
567a342296 Integration: Color Table presets after Load, .act by file name (#5407)
- Color Table dialog: after Load…, picking a preset in the Table menu still
  applied (OK) and saved (Save…) the loaded colors, as the hidden `colors`
  field won over the menu (`image.mode.colorTable` applies `colors` over a
  preset). The dialog now drops the loaded colors from the fields OK sends
  once a preset is picked; Save… uses them only while the menu is on Custom.
- Swatches import from bytes (browser, drag and drop) sniffed the format by
  content, and .act has no signature, so an .act whose first color is
  00 01 xx or 00 02 xx went to the .aco parser and failed. A file named
  .act now imports as .act; .aco and .ase are still detected by content.
- Removed "Export Swatches as Color Table…" from the Swatches menu: Photoshop
  saves .act from Image > Mode > Color Table, not from the Swatches panel.

Co-authored-by: Abdul karim mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 02:26:18 +02:00
Higgs32584andClaude Opus 5.5 10ffd4b84f Merge upstream main into fix/5863-cjk-menu-mnemonics
Resolve conflicts with the shared checkmark column (#5705): menu rows and
submenu titles now go through menu_row_text, fed the decorated labels from
menu_keys::localized ("新建(N)…") so CJK rows keep their access keys. Keep
both the #5863 and #5705 tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:26:04 -04:00
David MannandClaude Opus 5.5 c9b0bb0cbc Integration: merge main, resolve conflicts (#4496)
fill_to (Paint Bucket export): keep main's `source` alias and explicit
colour (`Usng` = `Clr ` plus `Clr `, #7199) together with the PR's
optional `Tlrn`/`AntA`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 02:05:34 +02:00
Gabrieljr42 797c7852f9 fix(type): clear retained wght variation when resolving exact face 2026-10-20 21:01:11 -03:00
b484f45429 Integration: merge main, resolve conflicts (#5407)
apps/photocraft{,-web}: main added "cin" to OPEN_EXTS; kept it and the PR's
"act". apps/photocraft/src/services.rs: main gave File > Open one filter per
format (#6337) and checks with every_open_format_has_its_own_filter that the
filters cover OPEN_EXTS exactly, so "act" joins the Swatches filter.
i18n: both sides appended sections at the end of every catalog; kept both.

Co-authored-by: Abdul karim mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:49:54 +02:00
NetterSilverandClaude Haiku 5.5 8aa3b0668b Reject '+' signs in color picker hex field
Found while testing crates/ui-egui/src/color_picker_ui.rs:94-102 (parse_hex)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 01:43:02 +02:00
399e6a43c5 Integration: repair Gradient Maps saved by older builds on re-save (#8081)
Older builds wrote every generated `grdm` 2 bytes short of four-byte
alignment (86 + 20n bytes), which Photoshop refuses. The PR pads new
blocks, but opening such a file and saving it again kept the old block:
the keep and Reverse/Dither retarget paths reuse any `grdm` that parses
to the same Gradient Map. Such a block is now regenerated, like the
legacy `phfl` repair. Only blocks that are byte for byte the old writer's
output are matched, so other writers' blocks (stops in CMYK/Lab/Gray)
are still kept verbatim.

Test: legacy_unaligned_gradient_map_is_repaired_on_resave (146 bytes
without this change).

Co-authored-by: Sir Starch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:35:14 +02:00
Gabrieljr42 99a234d2e2 fix(type): resolve exact PostScript names in type.create
type.create stored the font field as a family name, so an exact
PostScript face such as Arial-Black warned and fell back. Resolve
only an exact PostScript match and keep that face's family, weight,
slant, and name. Unknown families still warn.

Fixes #8063

Signed-off-by: Gabrieljr42 <[email protected]>
2026-10-20 20:34:08 -03:00
d149007d0b Integration: merge the author's rebased head into the fix-up (#4935)
The author rewrote the branch to a75039792e (the same Duotone code as
b10c9b91f1, without its main merge). This merge puts their new head
into the fix-up unchanged; the tree is identical to 2d7c62055e.

Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:24:28 +02:00
yongjip aaf3492b81 fix(liquify): include cubic overshoot in source reads 2026-10-21 08:18:18 +09:00
152c9677da Integration: keep unreadable canonical type as a type layer (#4771)
A normal big-endian TySh that the typed reader rejects (an unknown text
descriptor version, a stub from another writer) opened as a text layer on
main, with the best-effort text and its TySh saved back verbatim. With the
PR it became a raster layer, and the PSD export strips TySh from rasters,
so open + save turned the type layer into pixels for Photoshop. Now only
foreign-order blocks and unsafe transforms become pixels; the old fallback
runs under the same transform gate (shared as
photocraft_text::psd::transform_is_safe). The warning no longer claims the
type data is kept.

Test: canonical_type_the_typed_reader_rejects_stays_type_and_saves_its_block
(descriptor version 17, ordinary matrix) fails without this change.

Co-authored-by: Sean Lavery <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 01:14:50 +02:00
David MannandClaude Opus 5.5 d41aae7849 Integration: merge main, resolve conflicts (#4771)
crates/text/src/psd.rs: keep both appended test modules (transform_safety_tests
and main's nonfinite_tests). docs/file-format-parity.md: add the #4758 item to
main's current change line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 00:54:54 +02:00
NetterSilverandClaude Haiku 5.5 67314ff606 Write DOMAIN_MIN/DOMAIN_MAX in the .cube writer
Found while testing crates/cms/src/lutfile.rs:227 (write_cube)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 00:46:33 +02:00
Sir Starch d1f6ab4f1b fix(psd): pad generated Gradient Map blocks (#8070) 2026-10-21 00:41:02 +02:00
2f002a4545 Integration: pin Overprint's PSD read/write, translate its label (#6002)
lfx2_roundtrip_all_kinds compares by writing both sides again, so a reader
or writer that drops FrFX `overprint` on both sides still passes. The new
test checks the parsed flag for off and on and for a stroke without the key
(other writers); it fails with either the reader or the writer reverted.

The new "Overprint" checkbox label had no row in any of the 20 catalogs, so
every non-English UI showed it in English, unlike its translated siblings.

Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 00:37:47 +02:00
Korg1ch 53b2e1fc9a test(ui-egui): page keys scroll a zoomed canvas a screen, a step, sideways and to its corners (#8057) 2026-10-21 01:35:08 +03:00
Korg1ch fdbac3ca03 fix(ui-egui): Page Up/Down, Home and End scroll the canvas, as in Photoshop (#8057) 2026-10-21 01:35:08 +03:00
David MannandClaude Opus 5.5 627bb8f1bf Integration: merge main, resolve conflicts (#6002)
docs/file-format-parity.md: both sides added revision-history rows; keep both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 00:11:01 +02:00
Vikram Bhaskaran ff61346024 Open mislabeled .psd files from their raster signature.
PNG/JPEG/TIFF bytes named .psd still go through codecs, as on main and
in Photoshop. Only an unrecognized .psd keeps the PSD parse error.
2026-10-21 08:57:47 +11:00
Gert Pellin a75039792e Layered TIFF corpus test: a flat Duotone is expected with its inks rendered
With the ink block read, an opened duotone has its real inks. A
single-layer one is saved flat, which writes the inks rendered as RGB
(flat.rs); the test compared that with the grey plate.

Refs #4814
2026-10-20 23:56:32 +02:00
Gert Pellin 60c05073a5 PSD Duotone: read and write the ink block (names, colours, curves)
The Duotone Color Mode Data is undocumented. From files Photoshop 2025
made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as
in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four
transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20,
..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain
and eleven overprint colours.

- Import interprets the inks, so an opened duotone shows them instead of
  its grey plate; the block is still kept raw and written back while the
  inks are unedited. A colour-book ink (not readable here) shows as black
  with a warning.
- Export writes a block for duotones made in PhotoCraft (RGB ink colours,
  curves on the 13-point grid or sampled at it, default dot gain,
  computed overprints), so every 8/16-bit Duotone is saved as Duotone.

Photoshop opens such files as Duotone, and a black monotone whose curve
is 0 up to 60 % density stays white exactly up to 60 % there.

Refs #4814
2026-10-20 23:56:28 +02:00
0073b395d4 Integration: merge main, resolve conflicts (#5990)
docs/file-format-parity.md: main added three 2026-10-15 revision rows
(DDS save, PNG sTER, PSD guides) where the PR added its own; keep all four.

Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:50:13 +02:00
723dbe943f Integration: test that PSD export writes antialiasGloss (#5990)
lfx2_roundtrip_all_kinds compares re-serialized bytes, so dropping the
new `.with("antialiasGloss", ...)` line from write_one still passed every
test: both sides then lose the flag the same way. The new test writes a
Bevel with the Gloss Contour's Anti-aliased on and reads the flag back;
it fails with that line removed.

Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:49:52 +02:00
2d7c62055e Integration: Duotone colour-book inks, 8-bit only, curve ends (#4935)
- A PANTONE (colour-book) ink is stored by its book key, so it read as
  black: Photoshop's usual Black + PANTONE duotones showed, exported flat
  and converted to RGB much darker than the grey plate before. Photoshop
  writes every ink's Lab equivalent in resource 1066; it now gives the
  ink its colour. Without it, the grey plate is shown, as before.
- Ink colours go through channel_map::decode_known_color (HSB inks were
  black with a "colour book" warning; CMYK is colour-managed).
- Photoshop has only 8-bit Duotone and refuses to open a 16-bit Duotone
  PSD: a 16-bit Duotone is written as Grayscale with the warning again.
- A curve on the 13-point grid without a 0 % or 100 % point was written
  with -1 there, which Photoshop refuses to open: both ends are filled.
- A layered TIFF of an Indexed, Bitmap or Duotone document says it is
  written in its pixel format (CodeRabbit's thread at tiff_layers.rs).

Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:46:51 +02:00
David MannandClaude Opus 5.5 3b63a27a88 Integration: merge main via the #4816 fix-up, resolve conflicts (#4935)
#4935 is stacked on #4816. Merging our reviewed #4816 fix-up (baa854341b,
which already contains origin/main) brings both up to date; no conflicts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 23:26:58 +02:00
Vikram Bhaskaran c41ca8b9f1 Merge origin/main into feat/iff-read.
Keep Amiga IFF in the gaps changelog and add an Open dialog filter for
iff/lbm/ilbm so named types still cover OPEN_EXTS.
2026-10-21 08:01:33 +11:00
Sir Starch 480c12304d Pencil: align click and line boundary snapping (#4895) 2026-10-20 23:00:18 +02:00
Matthias Schlich d104f7015f fix(filters): Clouds and Fibers move with their smart object
Their noise was sampled in document coordinates, so moving a smart object re-rendered a
different texture with the same params. The pattern is now anchored at the filter's bounds:
the object for a smart filter, the canvas on a layer. Fibers, like Clouds, takes the canvas
as its bounds even with a selection, which only masks it, so layer results stay as before
(CodeRabbit review on #6160).
2026-10-20 22:49:04 +02:00
jlangeandClaude Opus 5.5 a67b6bca68 Print: keep the printable area to the driver's on a native print, off PDF output (#7782)
A fitted native print drops a caller's printable area and fits only the one the
driver reports for this paper. The Print dialog drops it whenever output or a
preview is set, also when that change and the confirm land in one frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:47:43 +02:00
Sir Starch 3bd2274aa2 docs(codecs): document JPEG metadata helpers 2026-10-20 22:47:20 +02:00
Vikram Bhaskaran c0eb60dc84 Merge origin/main into fix/5258-psd-open.
Keep PSD routing in io. Use main's per-format Open filters, which
already include Photoshop.
2026-10-21 07:31:19 +11:00
Sir Starch f10194c028 Fix: omit JFIF APP0 from CMYK JPEG (#8008) 2026-10-20 22:29:51 +02:00
Matthias Schlich 0a7325af6e Merge origin/main into feat/psd-render-smart-filters
# Conflicts:
#	docs/file-format-parity.md
2026-10-20 22:20:37 +02:00
LosminekandClaude Opus 5.5 d6e0f0c48b Merge remote-tracking branch 'upstream/main' into cs-catchup
The only conflict was the end of cs.tsv: this branch appends the
catch-up block and main appended the Czech rows of #6546 (Memory Usage),
Liquify's Show Backdrop and #1327 (DDS Options). Both blocks are kept;
no key is duplicated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:13:30 +02:00
Vikram Bhaskaran a7d623b313 Merge origin/main into fix/5021-load-actions-menu.
Keep both the Actions panel menu tests and main's recorded layer-name
labels in actions.rs.
2026-10-21 07:00:31 +11:00
Vikram Bhaskaran 1fd9ea8cfa Merge origin/main into pr/6498-comp-order.
Keep Move Up/Down catalog rows together with main's newer strings, and keep both the reorder tests and the apply-by-name test.
2026-10-21 06:59:45 +11:00
seotarek 121002e1b9 fix(atn): guard radial_blur_to with pixel_target and test mask target (#7234) 2026-10-20 22:49:15 +03:00
denstu473 851ca348a7 fix(format): persist Windows save publication before acknowledgement
Flush NTFS directory publication after replacing a saved file. Report a
failed barrier as uncertain durability instead of successful Save. Flush
directory-bundle objects before the manifest, including on same-writer
retries, and stop before garbage collection when publication fails.

Add regression tests for failed barriers, retained old objects, cached
object retries, and directory handles. Keep existing Windows metadata
preservation and the separate Unix flush-error policy.

Validation: 130 format tests, 657 I/O corpus tests, clippy, layers, wasm,
12 acknowledged-save power cuts, six publication/cleanup cuts, and 1,368
native fault cases with exact archive comparisons. Existing preview-only
failure ordering remains tracked by #7548/#7549.

Fixes #7858.
2026-10-20 21:24:23 +02:00
jlange 1f1cc11362 Merge remote-tracking branch 'origin/main' into print-fit-printable-7782 2026-10-20 21:23:52 +02:00
seotarek ee042fc49f feat(ui): nest light and dark themes into submenus (#3782) 2026-10-20 22:14:37 +03:00
NetterSilverandClaude Haiku 5.5 b85b638e77 Keep '#' inside quoted .cube TITLE when parsing
Found while testing crates/cms/src/lutfile.rs:111 (parse_cube) and :227 (write_cube)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 18:15:46 +00:00
NetterSilverandClaude Haiku 5.5 acc3420c6d Keep previous crop length when normalizing rounds to zero
Found while testing crates/ui-egui/src/crop_size.rs:86-107 (format_length / normalize_length)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 20:14:50 +02:00
tessillustrations-droidandClaude Opus 5.5 b13847dc37 Layers panel: a group's layers indent past the group
Inside a group (or an artboard), a layer's indent only made up for the
disclosure triangle it doesn't have, so its thumbnail and name lined up under
the group's and the hierarchy didn't show. A layer inside a group now indents
one step more than its depth. The step comes out of the indent budget, so a
narrow panel still squeezes the indent before the name. Top-level rows don't
move.

Test: a_groups_layers_indent_past_the_group (fails before: child and group
names start at the same x).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:04:19 +02:00
seotarek 86b7a4f269 fix(atn): match exact 0.5 center and assert state preservation on error 2026-10-20 20:58:42 +03:00
seotarek 7a9a4341a9 fix(atn): simplify map_or to is_some_and to satisfy clippy (#7234) 2026-10-20 20:58:42 +03:00
seotarek c4ca741522 fix(atn): reject off-center radial blur in .atn export (#7234) 2026-10-20 20:58:42 +03:00
tessillustrations-droidandClaude Opus 5.5 a6cff8b649 Snapping: artboard edges and centres are snap targets
With Snap To › Layers on, other layers, selections, shapes and boards now snap
to an artboard's edges and centre, as in Photoshop. An artboard is a group, and
groups weren't snap targets, so a board offered nothing; its children still
offer their own edges. A board being moved (in the exclude list) doesn't snap
to itself.

Test: an_artboard_offers_its_own_edges.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:47:19 +02:00
Sir Starch e81fa94f3f BMP export: use the full palette for opaque pixels 2026-10-20 19:37:31 +02:00
dotCooCoo eeb4f5cc1e Merge main into fix/save-added-extension-asks-before-replacing 2026-10-20 10:25:03 -07:00
Finn Newick 15029dfcee Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	apps/photocraft-cli/src/lib.rs
#	crates/engine/src/file_cmds.rs
#	docs/file-format-parity.md
2026-10-20 18:20:08 +01:00
xsvm 331c7f38a5 perf(raster): read 16- and 32-bit rows into RGBA with the colour model resolved per row 2026-10-21 01:17:03 +08:00
Sir Starch d470faa65f Merge remote-tracking branch 'upstream/main' into fix/liquify-freeze-boundary-7989 2026-10-20 19:11:40 +02:00
Sir Starch 45ed3ccb2e fix(liquify): keep frozen pixels stable at mask boundaries (#7989) 2026-10-20 19:11:36 +02:00
Lucenx9 82f456b262 fix(geom): reject extreme warp split indices without overflow 2026-10-20 19:11:01 +02:00
Sir Starch 0ed0f286ce Merge upstream/main into fix/bmp-indexed-export 2026-10-20 19:10:21 +02:00
Florent Siegenthaler 9b88a99b28 Merge branch 'main' into fr-complete-catalog 2026-10-20 19:09:01 +02:00
Elvis33LE d22b6ec7b4 automation: edit.installedProfiles returns installed profiles, not host paths
The workspace test's filesystem-path classifier reads the command's
registry description, which mentions profile paths; the command takes no
host filesystem path as input, so it joins the allowlist beside
prefs.get/prefs.reset.
2026-10-20 19:04:18 +02:00
Elvis33LE 40b3bf9b6b test(gpu): reword the tile-growth doc comment for what main does
The comment still described the reverted encoder-batching design; on main
keep_overlap submits its own copy, so the pinned property is that the
kept overlap never overwrites tiles rewritten in the same update,
whatever the submission shape.
2026-10-20 19:02:51 +02:00
Higgs32584andClaude Opus 5.5 f6ed751a27 Open: a file without type never waits for the font scan (#7332)
The shell checks an opened document's missing fonts on the UI thread.
missing_fonts locked the shared text engine even for a document with no
type layers, and the engine's first use waits for the installed-font scan
started at launch, which takes minutes on some machines: the window froze
at 2% or 95% on the first open after every launch. A document without type
now returns at once, and a background open with type layers waits for the
scan on its worker ("Loading fonts") so the later UI-thread check is quick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:02:39 -04:00
Higgs32584andClaude Opus 5.5 b19293d293 Windows on ARM: auto picks DirectX 12 for Qualcomm graphics (#7537)
On a Windows ARM64 PC the Qualcomm Vulkan driver lost the device
mid-session (Layer Style > Inner Shadow edits), the window's egui-wgpu
renderer panicked on the lost device and PhotoCraft exited. With the
backend set to DirectX 12 the reporter could not reproduce it.

`auto` already ranks DX12 before Vulkan for Intel on Windows. Extend that
rule to Qualcomm, matching both the id its Vulkan driver reports (0x5143)
and the one DXGI reports ("QCOM", 0x4D4F4351). System Info names the
maker in its note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 12:59:22 -04:00
nrq6993-a11yandCursor 1bd60ab6fb Use catalog keys for Color panel slider mode labels (#7317).
tl!("HSB") and tl!("RGB") are missing from some locale TSVs; reuse HSB/HSL and RGB Color so every_tl_literal_is_translated passes.

Co-authored-by: Cursor <[email protected]>
2026-10-20 19:38:15 +03:00
xsvm 67a9c53f26 test(raster): build the test pixel without u8 overflow 2026-10-20 23:45:38 +08:00
xsvm a27fa7a19b perf(raster): tile content bounds compare rows block by block 2026-10-20 23:45:38 +08:00
Korg1ch 4d6d735ff9 test(io): developed CR2 and RW2 raws carry their EXIF, without orientation or pixel dimensions (#6957) 2026-10-20 18:28:13 +03:00
Korg1ch 7296d0d8b2 fix(io): a developed camera raw keeps the raw's EXIF for File Info's Camera Data (#6957) 2026-10-20 18:28:13 +03:00
jlange 566bccf6bb Merge remote-tracking branch 'origin/main' into vanishing-sample-alloc-7491 2026-10-20 16:17:11 +02:00
Brian TorresandClaude Sonnet 5.5 1ed03350e3 Docs: record the D4 white-level fix in the status lines and file-format revision history (#5978)
AGENTS.md asks for the status line and revision history of the parity docs to move with the change: gaps.md and file-format-parity.md now carry it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-20 07:05:35 -07:00
Brian TorresandClaude Sonnet 5.5 17cb398aa3 Raw: the Nikon D4 white level follows the sample depth (#5978)
The D4 profile's saturation level of 15520 was measured on 14-bit files but
applied to the 12-bit files the body also writes, whose samples are the
14-bit values shifted right by two. 12-bit D4 NEFs developed about 2.6 stops
darker than the same scene at 14 bits, with no warning.

The profile now records the depth its level was measured at, and
cameras::apply takes the stored sample depth: 14-bit files keep 15520, 12-bit
files get 3880. A synthetic D4-sized NEF at both depths must develop within
0.2 stop (it was 2.5 stops apart before); on the public 12-bit D4 files the
difference to the 14-bit twin is now within 0.02 stop.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-20 07:05:05 -07:00
NetterSilverandClaude Haiku 5.5 afeea2f643 Decode numeric character references in File Info XMP text
Found while testing crates/engine/src/file_cmds.rs:690 (xml_unescape), used by li_values, find_attr and read_file_info

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 13:42:22 +00:00
Korg1ch 5f8b124efa test(ui-egui): Slice Select's icon differs from Object Selection's and Slice's (#7859) 2026-10-20 16:27:12 +03:00
beterhans 5a903e913e Fix dropdown menus opening behind modal dialogs on macOS trackpad tap (#7113)
A trackpad tap-to-click delivers PointerButton pressed+released in the same
frame, so both the modal dialog and the reopened ComboBox popup request
move_to_top in one pass; egui's stable sort then leaves a previously used
popup (its static LayerId sits earlier in Areas::order, persisted via
ui.ron) behind the dialog.

Register ctx.set_sublayer(parent, child) for open Foreground popups inside
a Foreground parent (press_menu::above_parent), applied to PressCombo,
press_menu::show, popup_value_field, swatch_popup, crop presets and
layer-style contours.

Regression test: single_frame_trackpad_tap_keeps_dialog_dropdown_above_modal
and_selects_option (1-frame tap after a prior open/close, on the Indexed
Color dialog's Palette dropdown).

Note: the palette grid context menu added by the Indexed Color PR
(storytold/photocraft#7320) gets the same one-line above_parent_ui
treatment in a follow-up once both are merged.
2026-10-20 21:19:43 +08:00
wwewtech 6d2c6cc017 fix(atn): a Group Layers step keeps its explicit layer through .atn (#7236) 2026-10-20 16:57:48 +04:00
Korg1ch 5634b5155a fix(ui-egui): Slice Select gets its own icon, the Slice knife with a pointer (#7859) 2026-10-20 15:50:02 +03:00
NaufalDzz 0fb98f6d91 fix(prefs): say Rendering Mode applies at next launch next to it (#7939)
Preferences > Performance > Rendering Mode only takes effect when
PhotoCraft restarts, but the only "Applies at next launch." note sat at
the bottom of the tab in the Graphics block, so switching CPU/GPU looked
like it did nothing. The note now sits under the setting, as #1677 does
for System Title Bar, and goes with it when a search hides the row.
2026-10-20 19:44:49 +07:00
Nigel Ihza Firdausy f0aae59938 Merge upstream main and retain CPU/GPU Dissolve parity coverage 2026-10-20 19:44:22 +07:00
NetterSilverandClaude Haiku 5.5 ff2db1b83b Fix File Info showing 1/1 s for exposures just under one second
Found while testing crates/engine/src/file_cmds.rs:768

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 14:21:14 +02:00
Korg1ch 52312331d8 test(engine): Emboss saves as Embs with integer Angl/Hght/Amnt and loads back (#7946) 2026-10-20 14:56:00 +03:00
Korg1ch 38611da546 fix(engine): .atn save and load map Filter › Stylize › Emboss to Photoshop's Embs (#7946) 2026-10-20 14:56:00 +03:00
wstockandClaude Fable 5.1 e51f48d0e8 Zoom tool menu: Zoom In and Zoom Out last, as in Photoshop (#6005)
The Zoom and Hand tools' context menu put Zoom In and Zoom Out first.
Photoshop's lists Fit on Screen, 100%, 200% and Print Size, then Zoom In
and Zoom Out after a separator, which is where users look for them. The
rows are reordered (Fill Screen keeps its place by Fit on Screen); the
order test follows.

The control.rs agent test (ui.inspect over the Zoom tool's menu) and the
two context-menu docs now state the new row order.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-20 11:32:37 +00:00
NaufalDzz 0ce3a2ff2b fix(layers): align a lone layer in an artboard to that artboard (#7945)
With one layer selected and no pixel selection, Align used the canvas
bounds, so a layer inside an artboard jumped out of its board (to the
middle of the canvas, between other boards). As in Photoshop, it now
aligns to the artboard holding it; a layer outside any artboard, and an
artboard itself, still align to the canvas. An explicit `to` is unchanged.
2026-10-20 18:15:47 +07:00
Korg1ch 9819df5002 test(ui-egui): clicking a note shows its author and colour (#7940) 2026-10-20 13:59:18 +03:00
Korg1ch d26d32cafe fix(ui-egui): a selected note shows its author and colour in the Note options bar (#7940) 2026-10-20 13:59:17 +03:00
chiharumurabayashi-stack 6b5b64bfca fix(transform): keep artboard paper below child previews 2026-10-20 19:26:30 +09:00
xsvm 3db8cc1b1f refactor(liquify): use the crate's par helpers for the dab rows and the source bands
par::map and par::chunks_mut already pick rayon on native targets and run serially on wasm,
so the two hand-written cfg blocks go; the threshold gets a name.
2026-10-20 18:23:19 +08:00
kuvshin888 3af447a315 Type: hovering a served font fetches it after a short rest, not as the pointer passes
Hovering a row in the font menu previews that family on the canvas, and for a family the
host serves but hasn't sent yet the preview asks the host for the whole family
(`served::request`). Sweeping the pointer down the menu therefore downloads every family
it passes over, each one a font file, or a file per style for a static family.

A served family that isn't installed now previews once the pointer has rested on its row
for 0.25 s; moving to another row starts the wait again. Installed fonts (bundled, system,
or served and already fetched) preview at once, as before, and picking a font still
fetches it immediately.

Tests: a served family is asked for only after the rest, and passing over rows asks for
nothing; an installed family previews on the first frame.
2026-10-20 13:21:15 +03:00
Korg1ch 4fea6ffda4 test(ui-egui): Fixed Size fields show and take the unit picked on them (#7910) 2026-10-20 12:39:05 +03:00
Korg1ch 53c3ff543b fix(ui-egui): Fixed Size marquee width and height take any length unit (#7910) 2026-10-20 12:39:05 +03:00
xsvm 6d96c38c4e perf(liquify): a large dab computes its field nodes row by row in parallel
Since #4047 a stroke dabs every twentieth of the brush radius, four times as often as before,
and P30's 800 px update went from 9.6 to 35.9 ms on the perf runner. Each dab walked its
nodes (about 125,000 at 800 px) on one thread. A node reads only the snapshot and the field
as it was before the dab, and the writes land after, so rows are independent: dabs over
16,384 nodes compute their rows on the rayon pool (wasm stays serial). Same writes, same field.
2026-10-20 17:23:10 +08:00
Korg1ch 258e17b017 test(ui-egui): ⌘ over a thumbnail shows the load cursor and badge (#7824) 2026-10-20 12:10:41 +03:00
Korg1ch feb0ea6a5b fix(ui-egui): the pointer over a layer thumbnail shows what a ⌘-click loads (#7824) 2026-10-20 12:10:40 +03:00
xsvm dfba0544d1 perf(liquify): read and premultiply the full-resolution source in parallel bands
The full-resolution preview read the warped region's source and premultiplied it on one
thread, about a million pixels at an 800 px brush and most of each update (P30). Each band
of rows now reads and premultiplies in one pass on the rayon pool (wasm stays serial); the
conversion is per pixel, so the samples are the same.
2026-10-20 16:45:24 +08:00
Khawar Zamman WaniandClaude Opus 5.5 d51202c5a4 Smart Blur: 3.5x faster at radius 12 with identical output (#7921)
Neighbours are read at precomputed offsets into the window instead of
per channel through bounds-checked Image::get. Normal mode with 1-5
channels runs the interior (whole kernel in the window) offset-major
over each row with a branchless select: a rejected neighbour adds -0.0,
an exact identity, and every pixel sums in the same order, so the
output is bit-identical. Border pixels, the edge modes and 6+ channels
keep the per-pixel path.

24 MP RGBA8, High quality: r3 0.55 -> 0.23 s, r12 6.25 -> 1.78 s,
r100 3.64 -> 0.95 s. bench_filters2 gains r12 and r100 rows.

The old kernel stays as a test oracle, compared bit for bit across
colour modes, 1-8 channels, alpha, radii, qualities, modes, thresholds,
output rects and NaN/inf samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 13:50:17 +05:30
Korg1ch 295901cf3f test(ui): the shape dialog shows Width and Height in the picked unit and remembers it (#7786) 2026-10-20 11:02:35 +03:00
Korg1ch db2b55f66e fix(ui): Create Rectangle/Ellipse/Triangle/Polygon: right-click Width or Height to pick their unit (#7786) 2026-10-20 11:02:35 +03:00
Korg1ch 4a3f3ba665 test(ui): an Alt+right-drag in the Liquify dialog resizes the brush where it was, with no stroke (#7884) 2026-10-20 10:30:33 +03:00
Korg1ch 24b8b027e9 fix(ui): Liquify's Alt-drag brush resize keeps the brush in place and doesn't paint (#7884) 2026-10-20 10:30:32 +03:00
Andee CollardandClaude Opus 5.5 69af55ff7e gallery_sheet: reject an unknown GALLERY_ONLY filter or GALLERY_SET key
A misspelt filter rendered nothing and a misspelt setting rendered the
default, both silently (CodeRabbit review). Also documents apply and the
halftone test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 07:27:36 +01:00
nrq6993-a11yandCursor cf8a79190d Apply rustfmt to capture_key_press unit test call site.
Co-authored-by: Cursor <[email protected]>
2026-10-20 09:04:17 +03:00
Andee CollardandClaude Opus 5.5 53e3a059ee Filter Gallery › Halftone Pattern: a tone-keeping screen at 45°
Every pixel was pure ink or paper at any Contrast, dots could never fill
their cell (white crosses in the shadows), mid-greys came out the wrong
darkness and the dot grid was square to the image.

Halftone Pattern now keeps a continuous range of tones, as Photoshop
describes it:
- each pattern is a threshold (spot) function spread evenly over its
  cell: round dots on a 45° screen that grow from the centre, meet as a
  checkerboard of squares at 50% and fill the cell; rings round the
  image centre; horizontal lines
- each tone is offset (closed form, a 256-step table per Contrast) so a
  cell's average ink is exactly that tone: black stays solid and white
  stays paper at any Contrast
- Contrast is how steep the threshold is: soft "blurry squares" at 0,
  crisp dots at 50
- Size 1 is a fine screen (3.5 px, 25.5 px at 12)

The gallery_sheet dev test can render one filter with chosen settings
(GALLERY_ONLY, GALLERY_SET, GALLERY_TAG).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 07:03:55 +01:00
nrq6993-a11yandCursor b180cfd40d Merge upstream/main into stream/issue-5914-exposure-decimals
Resolve widgets.rs FieldOpts destructure: keep step_from_decimals (#5914) and extent from main.

Co-authored-by: Cursor <[email protected]>
2026-10-20 08:12:16 +03:00
nrq6993-a11yandCursor 5be2b29463 Merge upstream/main into stream/issue-5902-text-antialias.
Resolve render.rs by routing parallel composite through shape_antialias_coverage.

Co-authored-by: Cursor <[email protected]>
2026-10-20 08:11:03 +03:00
Kushagra Saxena 8f2f4220db Merge remote-tracking branch 'upstream/main' into atn-duplicate-inplace
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/atn_cmds/tests.rs
#	crates/engine/src/jobs.rs
2026-10-20 10:29:27 +05:30
nrq6993-a11yandCursor a99277109c Document #7190 shortcut-capture root cause and unit-test capture_key_press.
Co-authored-by: Cursor <[email protected]>
2026-10-20 07:55:10 +03:00
nrq6993-a11yandCursor 034788019e Restore top-level group auto-open during brush search (#7270).
Keep nested folders collapsible while filtering (#7257); only preset_list top-level headers force-open when the filter is non-empty.

Co-authored-by: Cursor <[email protected]>
2026-10-20 07:50:56 +03:00
Korg1ch aede66995d test(ui): Delete and Backspace delete the selected slice; locked and auto slices keep the layer (#7862) 2026-10-20 06:44:12 +03:00
Korg1ch 2c030a300c fix(ui): Delete with Slice Select deletes the selected slice, not the layer (#7862) 2026-10-20 06:44:11 +03:00
wwewtech 824f93a69f fix(atn): a preset-only Photo Filter step exports its resolved colour (#7235) 2026-10-20 07:20:08 +04:00
Higgs32584andClaude Opus 5.5 3d27feac18 Save: a plain Save records its Targa answer too
save_in_place_with journalled only the JPEG answer, so a replayed or scripted file.save with
tgaBits replayed at the default bits (CodeRabbit on #7878).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 21:45:07 -04:00
Codex 82a1f54cc8 Print: the no-listener case refuses through a seam instead of a port it hopes is free
The released-port version only proved the port was unused a moment earlier. Between
drop(free) and the connect, another process can be handed that exact ephemeral port,
and the case then reports a protocol error or blocks instead of Unreachable - the
same flake this branch was meant to remove, just quieter.

connect() now goes through open_tcp(host, port, addr, open), a private helper that
takes the connect call as a parameter. The refusal test decides what is closed, so it
owns no port: it asserts Unreachable, that the message names the endpoint, and that
the reason the connect failed survives. The end-to-end "nobody reachable" case now
names a host that can never resolve (RFC 2606) rather than a port nobody owns.

Fake-server protocol tests are unchanged, and no public injection point or dependency
was added.
2026-10-19 18:28:44 -07:00
Codex c06a7384a5 Print: a scoped IPv6 endpoint escapes its zone id in the request authority
`CUPS_SERVER=fe80::1%3:631` parses as `Tcp("fe80::1%3", 631)`, and host() wrote `[fe80::1%3]:631` into the HTTP `Host:` header and into the `ipp://` printer-uri. A bare "%" is not valid inside an authority - RFC 3986 section 3.2.2 reserves "%" for escaping - so a strict HTTP/1.1 server can answer 400 and the printer-uri is not a readable URI.

The zone is now written "%25"-escaped for the wire and kept raw for the socket, which is the only place a scope means anything (measured on Windows: `("fe80::1%3", 631).to_socket_addrs()` gives `[fe80::1%3]:631`).

The two reviews disagreed about the form. Codex-cloud (fork #68) cited RFC 6874; CodeRabbit here pointed out that RFC 9844 (Aug 2025) obsoletes RFC 6874 and suggested dropping the zone from the authority. Dropping it was considered and not taken: it would send `Host: [fe80::1]:631` and `ipp://[fe80::1]:631/...` for a server reached at `fe80::1%3`, naming a different and ambiguous link-local host from the one the socket opened to, and it would rewrite the printer-uri a queued printer is registered under. The escaped form is what CUPS, curl and the ipp:// URIs CUPS writes use, and current URL libraries (yarl) still read "%25" as the URI form of a zone while rejecting a bare "%".

Regressions: numeric zone ids in both authority forms, an interface-name zone, the raw host after parse, and the unchanged IPv6/IPv4/hostname authorities. The printer-uri assertion no longer echoes host() back, so it pins the authority the fake server receives. Reverting the escape fails the new assertion (checked).

Platform limit, unchanged by this commit: an interface-name zone (`fe80::1%eth0`) does not resolve on Windows (getaddrinfo 11001), so only numeric scope ids are usable there, and the client reports that as `Error::Unreachable`.

cargo test on a standalone copy of the crate: 11 passed / 0 failed; cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.
2026-10-19 18:18:55 -07:00
Higgs32584andClaude Opus 5.5 8f5ba769e3 Save: a plain Save keeps a script's JPEG or Targa answer when it is recorded
journal_as_plain_save relabelled a confirmed Save As over the document's own file only when the
step held nothing but its path, so file.save {"quality": 5} stayed a file.saveAs with the path
and a replay wrote the recorded file rather than the one played on. It now drops only the path,
keeping quality, jpegFormat and tgaBits; a step with any other answer (TIFF layers) keeps the
Save As (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 21:18:03 -04:00
Higgs32584andClaude Opus 5.5 df93387986 Save: a script's JPEG or Targa answer survives the changed-on-disk prompt
disk_change_prompt::intercept now keeps the save's choices in the dialog fields, and Replace
writes with them, so file.save {"quality": 5} over a file another app changed no longer asks
JPEG Options after the prompt or drops a tgaBits (CodeRabbit on #7878).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 21:04:09 -04:00
7a2068c624 JPEG Options: a plain Save confirmed in the dialog records its answer, and replays it
A File › Save that confirms the document's own flat JPEG was recorded as `file.save {}`, which
dropped a quality or format changed in JPEG Options; playing the action wrote at the file's own
quality instead. The step now carries the answer (`plain_save_step`: `quality` left out when the
file's own quality was kept, `jpegFormat` when Baseline), and a replayed `file.save` writes with
it, without asking (`save_in_place_with`). Ported from #7878.

Co-authored-by: kayrockscreenprinting <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:54:17 -04:00
e647e102f3 Save As JPEG: JPEG Options dialog (quality 0–12, Baseline / Optimized / Progressive) (FILE-215-19)
Save As and Save a Copy to a JPEG now ask for JPEG Options before writing,
as Photoshop does. Re-creates #4330 on current main and folds in the
unique parts of #5580 (#5563).

- codecs: `JpegFormat` (Baseline, Optimized, Progressive) on
  `EncodeOptions`. Progressive writes SOF2. Baseline Optimized fits the
  Huffman tables only at 4:4:4 (Gray, CMYK) or with Progressive: fitted
  tables make the baseline scans non-interleaved, which zune-jpeg 0.5.15
  decodes wrongly with 4:2:0 chroma (zune-image#419), as Save for Web
  already works around.
- engine: a `jpegFormat` save param (file.saveACopy, Batch, Image
  Processor, Layers to Files and every SaveOpts user; anything but the
  three names is BadParams before writing) and a shared 0–12 -> 1–100
  `jpeg_quality()` that maps NaN/inf to 100.
- ui-egui: `jpeg_options_ui` (Quality 0–12 with Low/Medium/High/Maximum
  presets, Format Options), parked like Targa Options. It starts at the
  opened JPEG's own quality (`file_encoding`, #4032), else the Export
  preference; left there, OK writes that quality exactly and the step
  records no `quality`. `SaveChoices` (Targa bits + JPEG) replaces the
  bare `tga_bits` through file.save / file.saveAs / app.save, replay and
  Save a Copy; automation never prompts. `write_answered_save` is shared
  with Targa Options. `park_at` + `Start` let any save seed the prompt.
  A File › Save that confirms the document's own flat JPEG is still
  journalled as a plain `file.save` (#3531).
- CLI: `--jpeg-format`.
- i18n: five new strings in all 20 catalogs (#4330's duplicate de/es/zh
  `Low`/`High` rows dropped: main has them since #4008).
- Docs: book raster-formats JPEG bullet, control protocol, file-format
  parity, gaps G10, target-app parity; scorecard FILE-215-19 done.

Co-authored-by: SckraT <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:28:38 -04:00
Hector VillarrealandClaude Opus 5.5 e1a7065071 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 18:14:34 -06:00
tareqziad1998 7c479e9282 docs: regenerate the scorecard (TYPE-218-2 partial) 2026-10-20 00:07:06 +00:00
tareqziad1998 11fdce621a Merge upstream main into paragraph-direction-ui
Resolves the conflicts with main: the 20 catalogs keep main's new rows with the paragraph
direction rows after them, tool_reset.rs takes main's line plus type_direction, and gaps.md
re-applies the G8 entry on main's latest version.
2026-10-20 00:03:06 +00:00
vangel76 cd55b63b4f fix(move): show Align and Distribute in the Move options bar of non-Pro themes 2026-10-20 01:38:05 +02:00
Michael Higgins 468b96d57d Merge branch 'storytold:main' into fix/321-new-document-color-profile 2026-10-19 19:34:27 -04:00
Hector Villarreal dbdd8ff66e Merge remote-tracking branch 'upstream/main' into fix/7316-color-chips-centre
# Conflicts:
#	crates/ui-egui/src/panels.rs
2026-10-19 17:32:18 -06:00
Michael Higgins d821cb99b4 Merge branch 'storytold:main' into fix/auto-blend-seamless-gain 2026-10-19 19:31:00 -04:00
Raincloud d5338e5044 Merge remote-tracking branch 'origin/main' into fix/touch-double-tap-jitter 2026-10-19 16:56:47 -06:00
Hector VillarrealandClaude Opus 5.5 f0501d9721 Re-run CI (flaky automation bridge test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:55:42 -06:00
JGF 65879ba0fb Shortcuts: Ctrl+F4 closes the document beside Ctrl+W (Windows/Linux)
Photoshop on Windows closes the current document with Ctrl+F4 as well as
Ctrl+W. PhotoCraft bound only Ctrl+W, so Ctrl+F4 did nothing.

Add ("file.close", "Cmd+F4") to prefs::ALTERNATE_SHORTCUTS off the Mac
(macOS keeps Ctrl+F4 for window focus, and Photoshop for Mac has no such
key). It goes through the same guarded Close, so unsaved changes still
get the save prompt; the test checks both cases.
2026-10-19 18:43:25 -04:00
Hector VillarrealandClaude Opus 5.5 7983ea24c3 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:25 -06:00
Hector VillarrealandClaude Opus 5.5 8f0a484298 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:22 -06:00
Hector VillarrealandClaude Opus 5.5 9f1083d31e Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:15 -06:00
Hector VillarrealandClaude Opus 5.5 b3224447a9 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:12 -06:00
Hector VillarrealandClaude Opus 5.5 d824505598 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:08 -06:00
Hector VillarrealandClaude Opus 5.5 743488ee56 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:05 -06:00
Hector VillarrealandClaude Opus 5.5 704c60a095 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:27:55 -06:00
Hector VillarrealandClaude Opus 5.5 fc2d012b95 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:27:51 -06:00
Hector VillarrealandClaude Opus 5.5 46d48523a7 GPU parity: a bevel gloss contour that starts at 100 % (#7360)
Rolling Slope - Descending, as on the type in the report. Main's shader fails
it (CPU and GPU differ by 131/255); this PR's matches the CPU render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:26:17 -06:00
Hector Villarreal b517b6af02 Merge remote-tracking branch 'upstream/main' into fix/7360-type-bevel-fill 2026-10-19 16:23:08 -06:00
Omar Saidahmed ab16e0b4b7 document the shared hsb conversions 2026-10-19 17:59:37 -04:00
NetterSilverandClaude Haiku 5.5 e371bc34e3 Refuse strict .atn export of Apply Image with a blending mask
Fixes #7247

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 21:52:32 +00:00
NetterSilverandClaude Haiku 5.5 8de2db8764 Refuse strict ATN export of Select and Mask with brush strokes
Fixes #7539

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 23:42:10 +02:00
Cl4VD3andClaude Opus 5.5 811eb3f06a Previous/Next/First/Last Brush: translate the command labels
The four labels show in the Keyboard Shortcuts dialog and the command palette, like the brush size and hardness rows next to which they go in all 20 catalogs. The test that checks those rows in every language now covers the four as well.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:41:25 +02:00
Cl4VD3 168f271b33 Merge remote-tracking branch 'origin/main' into pr/brush-cycling 2026-10-19 23:41:09 +02:00
KLBMinhLong 5c8bea2b6a docs: regenerate the scorecard after rebasing on main (24 of 169) 2026-10-20 04:40:01 +07:00
seotarek adaa5f5f83 test(ui): verify View -> Snap off persists across restarts 2026-10-20 00:30:11 +03:00
seotarek 422bea8beb fix(ui): ensure turning View -> Snap off disables all snapping (#5782) 2026-10-20 00:30:11 +03:00
Brian TorresandClaude Opus 5.5 d11f2b6c84 Free Transform: the test budget fits 2^20 px at any depth (#7807)
The cfg(test) budget was 4 MB, 2^20 px of 8-bit RGBA only, so the turned
crop test that landed in #5007 (an 862 x 803 px layer at 16 and 32 bits)
was refused. It is now 2^20 px of the widest format (32-bit CMYK with
alpha, 20 MB), so whatever the old 2^20 px test limit let through still
fits; the refusal tests stretch far enough to stay over it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:26:40 -07:00
NetterSilverandClaude Haiku 5.5 a9df7b787b Refuse .atn export of crops with output size or resolution
Fixes #7191

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 23:25:37 +02:00
seotarek 55dbbdf060 Merge remote-tracking branch 'upstream/main' into fix/atn-numeric-layer-ref-7248 2026-10-20 00:08:40 +03:00
NetterSilverandClaude Haiku 5.5 35c7065692 Refuse .atn export of resolution-only Image Size without keepPrintSize
Fixes #7245

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:56:38 +02:00
Peppe 2e845117b9 feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 22:56:06 +02:00
KLBMinhLong 034edc0434 Zoom Resizes Windows: the screen stays a hard cap below the minimum window size (review) 2026-10-20 03:55:14 +07:00
KLBMinhLong 0b5f9df51e Preferences: Zoom Resizes Windows resizes floating document windows, and the Zoom tool shows it as Resize Windows to Fit (#204) 2026-10-20 03:55:14 +07:00
NetterSilverandClaude Haiku 5.5 47f7c97a3b Refuse strict .atn export of a mask/channel-targeted Free Transform
Fixes #7195

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:48:58 +00:00
seotarek 7145df8ded test(atn): assert actions state unchanged after rejected select.rect export 2026-10-19 23:45:51 +03:00
Michael Higgins 16154d2cff Merge branch 'storytold:main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-19 16:42:55 -04:00
NetterSilverandClaude Haiku 5.5 48009ce53d Record one action call when playback pauses for a step dialog
Fixes #7365

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:40:02 +02:00
Peppe 3c6d1088a1 test(ui): the font menu's search field stays put while the list scrolls (#7792) 2026-10-19 22:33:23 +02:00
RaincloudandCursor 372a468309 fix(ui): tweak touch-only settings to be just a little looser
Raise touch double-tap slop to 64 px after 32 px still rejected casual Framework taps.

Co-authored-by: Cursor <[email protected]>
2026-10-19 14:31:27 -06:00
MarekSpodBiedry 21a91764bc Brush: with airbrush build-up on, number keys set Flow and Shift+number sets Opacity (#7785) 2026-10-19 22:30:41 +02:00
seotarek 64f1e0b61d fix(atn): reject non-replace select.rect modes in .atn export (#7194) 2026-10-19 23:17:53 +03:00
Theo Wilenius 6c65754567 Merge branch 'main' into fix/6286-guide-drop-on-either-ruler 2026-10-19 23:16:17 +03:00
Ian Swope 769cd2f725 Warp: Esc also stops the warp of a selected linked mask
The mask's warp under a selection ran with Interrupt::NONE, so a cancelled
job still finished it. It now gets the job's interrupt, as the pixels do.
2026-10-19 15:14:04 -05:00
seotarek fb47b479b1 fix(atn): export recorded layer.select with name parameter (#7185)
PhotoCraft's action recorder writes layer.select with a name parameter,
but select_to only checked for the layer key. This caused recorded layer
selections to fail strict .atn export or be silently dropped when saving
via the desktop UI with skipUnsupported: true.

Support both name and layer parameters, reject duplicate name occurrences
that cannot be faithfully represented in Photoshop .atn descriptors, and
add regression tests.

Fixes #7185
2026-10-19 23:12:21 +03:00
Michael Higgins 3b0f0a7ca4 Merge branch 'storytold:main' into fix/auto-blend-area-past-canvas 2026-10-19 16:04:58 -04:00
Michael Higgins 5a17d92f03 Modify resume action to discard result
Change resume action to ignore the result.
2026-10-19 15:51:30 -04:00
Hamza e71fa3ab16 Actions: read a lost setting the way the command reads it
preserve was compared against the playback default "squareness", but
the command reads anything other than the exact string "roundness" as
squareness, so "Squareness" plays as the event does and lost nothing by
export -- and was refused all the same.

The table now says how each setting behaves on replay rather than only
what replay uses: ReplaysAs for a value replay substitutes (a seed, a
blur centre), DiffersAt for a setting whose one differing spelling is
all the command distinguishes.
2026-10-20 00:47:08 +05:00
Hamza a22fd1a788 Check the action survives a refused save
A refused save must leave the action alone: its step is still there to be
played. The test now compares the action's steps after the rejected save and
again after the `skipUnsupported` one, rather than taking the result of the
save as the whole story.
2026-10-20 00:47:08 +05:00
Hamza e5c906b49b Actions: a setting Photoshop's event cannot hold refuses the step
Crystallize is mapped with one field, `ClSz` for `cellSize`, while the command
takes `{"cellSize":3..300=10,"seed":u32=0}`. Photoshop's `Crst` descriptor has
nowhere to put a seed, so `seed: 37` was dropped on export and replay seeded
from 0 -- a different pattern, with `skipped` empty and nothing in the file to
say so. The report measures 583 differing colour components at every bit depth.

Auditing every mapped event against the params its command accepts found two
more of the same kind:

  filter.pixelate.crystallize  seed               replay seeds from 0 (#7668)
  filter.blur.radialBlur       centerX, centerY   replay spins about the middle
  filter.other.minimum/maximum preserve           replay keeps squareness

(`layer.flattenImage`, `tools.swapColors` and `filter.blur.blur` also take params
their event has no field for, but those are targets and selectors rather than
settings that change the pixels.)

`UNHOLDABLE` names those settings with the value the event plays as. A step that
leaves the setting there exports exactly as before; a step that sets something
else is refused, the way `pixel_target` already refuses a filter aimed at a
mask. No descriptor key is invented for them: without a Photoshop-written file
to read, a guessed `FlRs` in `Crst` or `Cntr` in `RdlB` would be a clean-room
violation and could make the file wrong in Photoshop too.

Fixes #7668.
2026-10-20 00:47:08 +05:00
Michael Higginsandcoderabbitai[bot] 4a53af3598 Update crates/io/src/flat.rs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-19 15:45:34 -04:00
NetterSilverandClaude Haiku 5.5 8591ca1904 Scale layer-effect noise by coverage so it follows the glow
Fixes #7833

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:44:44 +00:00
Brian TorresandClaude Opus 5.5 c5ba77e5d9 Free Transform: refuse a result the warp would clip, not only one too large (#7807)
The warp places pixels within +-2^20 px (WARP_REACH) and clamps the rest
away. The byte budget admitted results wider than that (a 2,000,002 px
strip fits 8192 MB), and a plain move past it already emptied the layer
without an error. `warp_output_clipped` says when the result would be cut,
and the budget check refuses it: "would reach past +-1048576 px, the
farthest a transform places pixels". Docstrings for the touched functions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:20:03 -07:00
Brian TorresandClaude Opus 5.5 f1a3e61268 Copy: the notice text copies after the pointer leaves it too; test docs (#7807)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:15:32 -07:00
Codex 909e1cdd84 Print: an IPv6 endpoint writes its authority in brackets, and a mistyped one stays unreachable
Codex-cloud's review of this fix found two more places where an IPv6 print server did not work.

host() feeds the Host: header and the ipp:// printer-uri, and it wrote the endpoint the way it is stored: "::1:631". The last colon reads as the port separator, so CUPS got a malformed authority and a malformed printer-uri even though detect() could now connect. An IPv6 host is bracketed there, and in Display, which is what the user is shown; to_socket_addrs still gets the bare address, which is what it needs.

A bracketed endpoint with anything else after the bracket - "[::1]:abc", "[::1]:70000", "[::1]junk" - fell back to the default port, so a mistyped CUPS_SERVER was accepted whenever something answered on 631 and jobs went to an endpoint nobody configured. The whole string now stays the host: it cannot resolve, so detect() reports it unreachable.

Tests: an_ipv6_endpoint_writes_its_authority_in_brackets, and three mistyped endpoints in the parse test. Both fail before this commit and pass after. photocraft-ipp 11 passed / 0 failed; clippy --all-targets -- -D warnings and fmt --check clean.
2026-10-19 12:13:08 -07:00
Brian TorresandClaude Opus 5.5 a5ebdaaa30 Free Transform: a result may take what Memory Usage allows, not 2^30 px (#7807)
The fixed 2^30 px cap refused any transform of a stitched layer past about
1 GP, on any machine. A result is now priced in bytes at its pixel format
(with the alpha the warp adds) against Preferences > Performance > Memory
Usage (default 8192 MB), which Session::apply_prefs sets. The report's
37708 x 35259 px 8-bit result (5072 MB) now transforms: 10.7 s, 6.1 GB on
top of the source. Past the allowance the message names it:
"... (10144 MB), more than the 8192 MB Memory Usage allows (Preferences >
Performance)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:35 -07:00
Brian TorresandClaude Opus 5.5 a0983e6105 Free Transform: the warp writes each tile as it finishes (#7807)
Every finished tile kept its f32 pixels until the last one was done, 16
bytes a pixel of an RGBA result on top of the result itself. Writing each
tile as soon as it is warped holds only the tiles in flight.

100 MP 8-bit layer (release, M4 Max, 16 cores), extra memory per result pixel
and time: scale 110 % 16.4 -> 4.8 B, 0.8 -> 0.6 s; rotate 15 deg 12.5 ->
3.4 B, 0.8 -> 0.5 s; 90 % + 15 deg bilinear 12.7 -> 3.4 B, 0.5 -> 0.3 s;
90 % + 15 deg bicubic 19.9 -> 12.4 B (its pre-reduced copy of the
source), 1.0 -> 0.8 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:15 -07:00
Brian TorresandClaude Opus 5.5 818de14222 Free Transform: document the budget check and its refusal test (#7807)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:08 -07:00
Gustavo Ziger 41682288b2 docs: regenerate scorecard after main merge 2026-10-19 16:06:34 -03:00
00xJ0 5faf499d5c Shortcuts: custom bindings on PageUp/PageDown (and other multi-word keys) fire again (#6827)
normalize_shortcut title-cased every named key, so a binding set to Shift+PageUp was saved as Shift+Pageup, which egui's case-sensitive Key::from_name rejects: the shortcut never fired. Multi-word key names (PageUp, PageDown, Open/CloseBracket, Open/CloseCurlyBracket, BrowserBack, IntlBackslash) now keep egui's spelling, and shortcuts::parse matches key names case-insensitively so bindings saved by earlier versions work too.
2026-10-19 12:05:57 -07:00
Peppe bff2609a68 feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 21:05:10 +02:00
00xJ0 abd3755d41 RecoveryStore::flush keeps each entry's key, so a later discard still removes it (#6464 review) 2026-10-19 12:05:01 -07:00
Peppe 7c504d79c2 Merge branch 'main' into feature/7792-search-font-sticky 2026-10-19 21:04:21 +02:00
Finn NewickandClaude Opus 5.5 6a24011ab8 test(atn): check the refused transform's error message and skip entry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:55:01 +01:00
Finn NewickandClaude Opus 5.5 b8f323a7c4 test(atn): check the refused step's skip entry and error message
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:53:00 +01:00
Brian TorresandClaude Opus 5.5 8b083b6e5b Copy: text selected in an error notice is copied, not the layer (#7807)
Outside text fields the clipboard keys go to the document, so ⌘C/Ctrl+C
over text dragged across in a notice (where a refused Free Transform's
message lands) ran Edit › Copy on the layer and the message was never
copied. Text selected in a label (dragged across, double/triple- or
shift-clicked) now takes ⌘C, as egui-winit's Copy event or as the Mac
menu's key press; ⌘X and ⌘V stay the document's, and a plain click on a
label leaves ⌘C to Edit › Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:45:35 -07:00
Codex 094bb727d3 Print: the no-listener test takes a port the OS says is free, not port 1
The last case of error_statuses_and_broken_replies_are_errors called printers(&Endpoint::Tcp("127.0.0.1", 1)) and expected Error::Unreachable. No port is guaranteed closed: on a FreeBSD CI VM something accepted on port 1 and reset the connection, so the client got as far as sending and the test failed with "print server reply: sending: Broken pipe (os error 32)" instead.

Bind 127.0.0.1:0, read its port and drop the listener, so the case uses a port the OS has just released, and keep the assertion. The fake() helper in the same file already takes its port that way.

A held port really does read differently, which is why the old case asserted a property of the machine rather than of the client: against a listener that accepts and closes immediately, printers() returns "print server reply: reading: An existing connection was forcibly closed by the remote host. (os error 10054)" on Windows, not Error::Unreachable.

cargo test -p photocraft-ipp: 9 passed / 0 failed, the changed test five runs in a row; clippy --all-targets -- -D warnings and cargo fmt --check clean.
2026-10-19 11:38:08 -07:00
Korg1ch ec0fc7cb0e test(atn): strict export refuses Minimum / Maximum with Roundness; Squareness round-trips (#7233) 2026-10-19 21:37:45 +03:00
Korg1ch 59b03f2948 fix(atn): a Minimum / Maximum step with Preserve: Roundness is not exported as Squareness (#7233) 2026-10-19 21:37:21 +03:00
Korg1ch b287a884f4 test(engine): a job applied during a later recording is not recorded there (#7289) 2026-10-19 21:36:24 +03:00
Korg1ch 07b9600561 fix(engine): a background job's step goes only to the recording it was started in (#7289) 2026-10-19 21:36:23 +03:00
Gustavo Ziger bd6723e0bf Merge branch 'main' into feat/zoom-animation-566 2026-10-19 15:34:36 -03:00
Finn Newick bafb2e1a2f Merge remote-tracking branch 'origin/main' into fix/atn-transform-selection
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 19:34:32 +01:00
Michael Higgins ceaac3dc25 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-19 14:32:43 -04:00
Finn Newick bfebeb4566 Merge remote-tracking branch 'origin/main' into fix/atn-new-layer-options
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 19:32:42 +01:00
Michael Higgins a66e6ed0bb Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-19 14:31:58 -04:00
Michael Higgins 2c96d9faa0 Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-19 14:31:41 -04:00
Michael Higgins fa9f11c7a7 Merge branch 'storytold:main' into fix/2095-android-menus 2026-10-19 14:31:10 -04:00
Michael Higgins c9889c8128 Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-19 14:30:53 -04:00
Brian TorresandClaude Opus 5.5 475df78c32 Free Transform: a result over the pixel limit says so, not "not enough memory" (#7807)
The 2^30 px refusal went through allocation::error, so it read "not enough
memory for transforming pixels (...)" although nothing had been allocated.
It now says the result is too large to transform and names the limit.
A real allocation failure still says "not enough memory".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:30:21 -07:00
Finn Newick cf8fccf2f3 Merge remote-tracking branch 'origin/main' into fix/atn-modify-bucket
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 19:29:47 +01:00
Codex ababe94f23 Print: an IPv6 print server is parsed as a host, not split on its last colon
Endpoint::parse split the server string on its last ':', which is wrong for an IPv6 literal: "::1" became host ":" on port 1, and "[::1]:631" kept its brackets inside the host, which to_socket_addrs cannot resolve on Linux or macOS. An IPv6 CUPS endpoint was therefore always refused by detect(), even when CUPS was listening on it.

A bracketed server now takes its host from inside the brackets and its port from after them. A bare address with more than one ':' is the host with the default port, because its last ':' cannot be told apart from a port separator; a zone id (fe80::1%eth0) carries no ':', so a port can still follow it. host:port, a bare host and a socket path are unchanged.

Test: "::1", "[::1]", "[::1]:631", "[fe80::1]:6310", "fe80::1%eth0:631", and the cases that already worked. Fail before, pass after.
2026-10-19 11:22:33 -07:00
NetterSilverandClaude Haiku 5.5 7b5f66be11 Don't paint from the sampled point when Alt is released mid-press
Fixes #7802

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:13:40 +02:00
Hector VillarrealandClaude Opus 5.5 b960c3a4e2 Type move zone: leave the paragraph box to the caret (#7606)
Inside a paragraph box, off the glyphs, a press places the caret, as in
Photoshop: the move zone now excludes it. The reach drops from 40 to 38 pt
so a press 40 pt outside a paragraph box still commits the text (#7606's
test). Photoshop measured 35 pt moving and 48 pt committing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:03 -06:00
Michael Higgins 34b5754efd Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-19 14:09:18 -04:00
Michael Higgins cd781bb25f Merge branch 'storytold:main' into fix/2095-android-menus 2026-10-19 14:08:55 -04:00
Michael Higgins e4546cc0bc Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-19 14:08:41 -04:00
Michael Higgins 84a1bf37f2 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-19 14:08:06 -04:00
Michael Higgins f9d883b5de Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-19 14:06:54 -04:00
Peppe 806e7ba37a feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 20:05:37 +02:00
Peppe 9af604e90c Merge branch 'main' into feature/7792-search-font-sticky 2026-10-19 20:05:01 +02:00
Hector Villarreal 4490717400 Merge remote-tracking branch 'upstream/main' into fix/5956-type-move-near-text 2026-10-19 12:02:31 -06:00
Peppe a58a6b100f feat(ui): search font input field sticky on scroll (#7792) 2026-10-19 19:53:29 +02:00
Hector VillarrealandClaude Opus 5.5 e499fef328 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:34:03 -06:00
Hector VillarrealandClaude Opus 5.5 4b9fde8787 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:59 -06:00
Hector VillarrealandClaude Opus 5.5 dc6cb9725b Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:53 -06:00
Hector VillarrealandClaude Opus 5.5 f4764f1f99 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:50 -06:00
Hector VillarrealandClaude Opus 5.5 a9a757543d Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:46 -06:00
Hector VillarrealandClaude Opus 5.5 b99db40906 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:42 -06:00
Hector VillarrealandClaude Opus 5.5 907923b014 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:38 -06:00
Hector VillarrealandClaude Opus 5.5 e154d3dafd Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:29 -06:00
Hector VillarrealandClaude Opus 5.5 a84aa1be34 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:33:26 -06:00
Hector VillarrealandClaude Opus 5.5 e795411547 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:32:54 -06:00
Finn Newick 9ea964fa51 Merge remote-tracking branch 'origin/main' into fix/atn-modify-bucket
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 18:22:03 +01:00
Raincloud 3c8fd3583c Merge remote-tracking branch 'origin/main' into fix/touch-double-tap-jitter 2026-10-19 11:21:27 -06:00
Finn Newick 54ede84037 Merge remote-tracking branch 'origin/main' into fix/atn-transform-selection
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 18:19:55 +01:00
Finn Newick 4cc24567f1 Merge remote-tracking branch 'origin/main' into fix/atn-new-layer-options
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 18:18:41 +01:00
Finn Newick 9d95f13a9d Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	docs/file-format-parity.md
2026-10-19 18:14:36 +01:00
Florent Siegenthaler ab6e831686 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks.
2026-10-19 19:10:16 +02:00
Daniel Campos RamosandOpenAI Codex 0689608619 PNG: name sTER modes by their specified eye order
Both sTER modes describe side-by-side subimages: mode 0 is cross-fuse (right eye first), and mode 1 is diverging-fuse (left eye first). Correct the enum and document comments without changing their serialized byte values. Add a labelled-pixel check against the literal mode bytes in PNG Extensions 1.4.0 section 3.6.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-19 14:08:44 -03:00
jlangeandClaude Opus 5.5 21fa6cf1dd Vanishing Point: read a sample's 2×2 in one call outside the window (#7491)
When the source window is skipped (cloning near to far toward the
horizon), four `read_pixel` calls looked the tile up four times and made
that regime ~20% slower than `main`. One `read_region_into` into the
reused corner buffer looks it up once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 19:06:14 +02:00
Hodd 1d69481349 Guides: draw the drag readout after ruler drags so it is current every frame 2026-10-19 18:54:31 +02:00
jlangeandClaude Opus 5.5 c706af6a37 Print: only a fitted print reads the printable area (#7782)
A print at a manual scale doesn't use the area, so it no longer
validates it (a stale one from a replayed action can't fail the print)
or asks the Windows driver for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 18:50:19 +02:00
Hector VillarrealandClaude Opus 5.5 21cca70465 Glow Range: document the 1..100 % range its slider and clamp use
The outer and inner glow commands documented range as 0..100, while the
slider starts at 1 % and the command holds values below it at 1 %, as
Photoshop's Range does. Document 1..100=50 (like Bevel's contourRange) and
test the boundaries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:38:51 -06:00
Brian Blakely 6e20764ab6 Add a draggable center diagram to Zoom radial blur 2026-10-19 12:25:50 -04:00
jlangeandClaude Opus 5.5 0e67ff8e4f Print: marks need room in the printable area only when fitting (#7782)
Also test a rejected printable area through `file.print`: the command
fails and leaves the document and the remembered print settings as they
were.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 18:22:56 +02:00
Kushagra SaxenaandClaude Opus 5.5 aa54979583 Record a duplicate's inPlace only when a copy is an artboard
Alt-drag, Cmd+Opt-drag and Free Transform on a copy duplicate with
`inPlace: true` for every layer, but the flag only changes anything for
an artboard. With in-place duplicates now refused on strict .atn export,
a recorded copy of a plain layer could no longer be exported, although a
plain Duplicate replays it exactly. Recording now drops `inPlace` when no
copy is an artboard, as `layer.select` already rewrites its recorded
params.

Refs #7200

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 21:17:40 +05:30
Hodd 362bdf135e Guides: show the X or Y position beside the pointer while dragging (#7680) 2026-10-19 17:47:09 +02:00
Gert Pellin 1489dafb4a fix(ui): guides draw over the Puppet Warp, Perspective Warp and Vanishing Point previews (#7781)
Free Transform's preview already goes under the guides (#5086), but the
on-canvas distort modes still painted their warped pixels together with
their pins and planes in distort_ui::draw_overlay, after the grid and
guides, so every guide vanished across the layer being warped. Split
each mode's drawing: draw_content paints the warped pixels (Puppet Warp,
Perspective Warp) or the paste (Vanishing Point) right after the
document, next to transform_tool::draw_content and under the grid and
guides; draw_overlay keeps the mesh lines, pins, planes and nodes over
them, as in Photoshop.

Test: guides_draw_over_the_puppet_perspective_and_vanishing_point_previews
checks the painted order preview < guide (< mesh/plane) for all three
modes; it failed for each of them before the fix.
2026-10-19 17:40:09 +02:00
Cl4VD3andClaude Opus 5.5 db28e5da03 Brush stepping: place groups by every preset before skipping unselectable names
Review (CodeRabbit): skipping a case-insensitive duplicate before grouping dropped its group's first appearance, so a group could step out of the panel's order. Groups and folders are now ordered from all presets, and unselectable presets are removed from the result afterwards. Test added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:31:15 +02:00
jlangeandClaude Opus 5.5 dd87a1b149 Print: Scale to Fit Media fills the driver's printable area (#7782)
`layout()` fitted the image to the paper minus a fixed 18 pt margin, so a
borderless page (the driver reports the whole sheet as printable) still
printed with a white border, and a driver margin wider than 18 pt could
clip the image. A new optional `printable` parameter (the rectangle
`file.print.page` reports) is now the area Scale to Fit Media fills and
centres in, with the 36 pt mark inset kept inside it and Emulsion Down
fitting its mirror. On the native Windows route the engine reads it from
the driver, so Print One Copy, the CLI and replayed actions match the
Print dialog, which passes it for its preview. Without one (CUPS, PDF),
the layout is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:14:20 +02:00
Kushagra Saxena 2401039887 contributors: credit ksaxena149 by their GitHub profile name 2026-10-19 20:40:13 +05:30
Kushagra SaxenaandClaude Opus 5.5 443e6a3fc8 Refuse strict .atn export of an in-place layer duplicate
Photoshop's Duplicate Layer has no in-place flag, so `layer.duplicate
{"inPlace": true}` was exported as a plain Duplicate and replayed with
the artboard copy beside the original. Strict export now fails on it and
skipUnsupported reports it, as for a Close with a document target.

Fixes #7200

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 20:40:13 +05:30
Rene Leonhardt 05bd004bc4 perf(format): reuse native save writers per document
Reuse unchanged tile and blob hashes and bound retained compressed payloads. Carry the writer through native save settings while preserving save identity and exact output.
2026-10-19 16:58:35 +02:00
Ni-zav 7a60608cdd fix(ui): preserve imported bevel shading precision 2026-10-19 21:39:32 +07:00
Gert Pellin b10c9b91f1 Merge branch 'main' into feat/4814-duotone-inks (via feat/4814-psd-palette-modes) 2026-10-19 16:33:25 +02:00
jlangeandClaude Opus 5.5 4277f12437 Vanishing Point: skip the source window read when it is disabled (#7491)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:44:01 +02:00
Hamza f309665694 Refuse a Selective Color channel the command would refuse
The scalar form's channels went through `jnum`, which returns `None` for
anything that is not a number, so `{"colors": "reds", "cyan": "35"}` exported as
an untouched range while the command errors on it
(`adjust_cmds::selective_from_params` → `percent`). The export would have
written an adjustment that is not the one recorded.

A channel that is present and not null must now be a finite number, or the step
cannot be written and takes the refusal path. `null` counts as absent, as the
command's `given` reads it.
2026-10-19 18:29:33 +05:00
PerchAnton 674bfb697b docs(atn): document Ripple field conversion and test helpers 2026-10-19 16:27:06 +03:00
Hamza 1f6dbf8415 Lasso: a short straight segment places a vertex instead of closing the outline
With the Lasso and Alt held (Photoshop's straight-segment mode), a click closer
to the last vertex than the radius for grabbing the first one ended the
selection instead of placing the vertex:

  three Alt clicks at (50,50) (90,50) (90,90)  ->  3 vertices
  a fourth at (85,91), 5 points away           ->  the selection was committed

The radius is `8.0 / point_zoom`, so every segment shorter than 8 points on
screen was impossible to draw -- the click meant to continue the outline
finished it, and tracing anything small ended the selection after three
vertices (#7629).

Both closing gestures stay; they stop sharing one radius. Clicking the first
vertex is aiming at a target and keeps the 8-point grab radius. Clicking the
last vertex again is the same click repeated, so it only has to allow for a
hand's jitter: 3 points. Enter still closes, Escape cancels, Backspace and
right-click retract, releasing Alt with the button up closes, and the Freeform
Pen still ends an open path by clicking its last point.

I could not reproduce this on the dedicated Polygonal Lasso: `polygon_click`
closes only near the first vertex, and egui counts a double click by the time
between clicks *and* their distance (`max_click_dist`, 6 points), so two quick
clicks further apart are not one. A probe placing a vertex 4 units from the
last, clicked in the next frame, keeps it.
2026-10-19 18:17:25 +05:00
jlangeandClaude Opus 5.5 e87ad39a21 Vanishing Point: perspective clone samples without allocating per pixel (#7491)
`sample` read a 2×2 region (plus a default-pixel Vec) and collected a new
Vec for every covered pixel of each dab. It now writes into caller-owned
scratch and reads its corners from the dab's source footprint, read once per
dab; corners outside it come from `read_pixel`. Output is unchanged; corner
coordinates saturate instead of overflowing at the edge of i32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:14:33 +02:00
Ian Swope a053f9bc4e Warp: apply a large layer's warp in the background, with progress and Esc to cancel (#1015)
Committing Edit > Transform > Warp ran on the UI thread: on an 8000x6000
layer the window froze for 6.9 s (Arc) or 9.3 s (Flag), with no progress or
cancel. edit.transform.warp now runs its edit through edit_job, as Free
Transform does since #4218: the mesh warp reports progress per tile and
stops when cancelled, and the transform session keeps its preview until the
result lands. Undo during the commit names Warp.

Most of a Flag warp's time was after the mesh warp: re-converting a layer
that already had alpha (2.0 s) and compositing the warp over a cleared
layer that held nothing else (2.3 s). The layer is now cloned, and the warp
is the result when the box held all of it. Flag drops to 1.4 s and Arc to
4.2 s inline.
2026-10-19 07:18:29 -05:00
Hamza 7835d05a54 Actions: a Selective Color step keeps the values of its scalar form
`image.adjustments.selectiveColor` takes a range two ways, and the command reads
both: the array form `"reds": [c, m, y, k]`, and the scalar form that names one
range and gives the channels it changes, `"colors": "reds", "cyan": 35`.

`selective_to` looked only for the arrays. A step recorded the other way
exported with an empty `ClrC` list -- an adjustment that changes nothing -- with
`skipped` empty and no warning on import, so nothing said the values had gone.
On a single red pixel the step produces [0.6509804, 0, 0, 1] run directly and
leaves [1, 0, 0, 1] after a round trip through .atn.

The rows are now built the way `selective_from_params` reads them: the arrays
first, then the scalar form over the range it names, so a step that gives both
exports what playing it would produce. An unknown range in `colors` still
cannot be written and refuses the step.

Fixes #7672.
2026-10-19 17:11:27 +05:00
Rene Leonhardt f64a143c55 perf(format): drop unhelpful Zstandard workspace reuse 2026-10-19 13:43:20 +02:00
Rene Leonhardt f1ba4854d0 docs(perf): record end-to-end workspace results 2026-10-19 13:15:12 +02:00
PerchAnton 9f5559e515 fix(actions): refuse lossy fractional Crystallize ATN export 2026-10-19 14:13:05 +03:00
Reynold Hu b0408549b7 ui: adapt the new font picker test to the recents argument 2026-10-19 18:33:33 +08:00
Reynold Hu 4d4ed4f0ad ui: the keyboard font step uses the stable order too 2026-10-19 18:33:33 +08:00
PerchAnton caca15106e fix(actions): refuse lossy fractional Ripple ATN export 2026-10-19 13:27:37 +03:00
Reynold Hu 699e2dc44b Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	docs/scorecard.md
2026-10-19 18:26:47 +08:00
Rene Leonhardt 02a63c94c2 perf(format): Reuse Rust Zstandard workspaces
Keep fresh compression for partial blocks and verify independent frames against the previous encoder. Record the isolated allocation-reuse effect.
2026-10-19 11:31:59 +02:00
PerchAnton 0acb707fe1 fix(actions): preserve effective Stroke width in ATN export 2026-10-19 12:28:43 +03:00
Khawar Zamman WaniandClaude Opus 5.5 6a0f6c4ce1 Merge origin/main into fix/marching-ants-device-pixels
canvas.rs: the ants moved to crate::ants here; main's clippy::nonminimal_bool rewrite of
the finite/clip guard (f8cd3e79) is applied there instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:26:40 +05:30
Khawar Zamman Wani 770e66c022 Merge remote-tracking branch 'origin/main' into fix/control-port-zero-logs-bound-port
# Conflicts:
#	apps/photocraft/src/portal_save/tests.rs
2026-10-19 14:24:43 +05:30
Khawar Zamman WaniandClaude Opus 5.5 75fe81865f CLI: say that the input-overwrite check guards against mistakes, not races
The paths are compared before the save reopens the output, so a link swapped in meanwhile
is not caught (CodeRabbit nitpick on #7455).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:23:45 +05:30
Hector VillarrealandClaude Opus 5.5 9aa9634a22 Export As estimate: size the 40 MP guard as the export does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 02:01:39 -06:00
Hector VillarrealandClaude Opus 5.5 b81c1725b5 Export As: show the size the file will have (#6595)
The estimate encoded a ≤512 px proxy and scaled its bytes by the pixel ratio,
which reads low for detailed images (0.36–0.81× here). Photoshop's Export As
shows the encoded size. Up to 40 MP the full export is now encoded off the UI
thread with the exporter's own settings; the proxy figure shows until then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:52:03 -06:00
RaincloudandCursor 155700deae ui: read double-click pos from the response hit
Prefer interact_pointer_pos on the response so release-only frames still arm second_click.

Co-authored-by: Cursor <[email protected]>
2026-10-19 01:43:25 -06:00
RaincloudandCursor dc3a5c3b59 test(ui): cover New Document touch double-tap with finger jitter
Harness taps share Touch+pointer events on one frame so second_click sees touch; mouse path still rejects a 20 px gap.

Co-authored-by: Cursor <[email protected]>
2026-10-19 01:43:13 -06:00
RaincloudandCursor 249a3baabe ui: tolerate finger jitter on New Document double-tap
egui's 6 px click distance rejects typical touchscreen double-taps; use a looser touch slop in second_click while keeping mouse tight.

Co-authored-by: Cursor <[email protected]>
2026-10-19 01:26:45 -06:00
Hector VillarrealandClaude Opus 5.5 9e22dda917 Test: a copy floated by the layer's own transparency is as solid as the original
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:26:36 -06:00
Hector VillarrealandClaude Opus 5.5 c1f20c239d Move tool: a selection lifts up to a pixel's own coverage (#7655)
Loading a layer's transparency as the selection and dragging it with the Move
tool left a ghost of a·(1−a). Photoshop lifts the selection's share of a
pixel's coverage, up to all of it, and leaves the rest, so its own
transparency lifts it whole. Opaque pixels split as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:07:47 -06:00
Hector VillarrealandClaude Opus 5.5 b8b8907f72 Type move reach: measure on screen with pixel aspect; leave other type to the Type tool
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:48:33 -06:00
Hector VillarrealandClaude Opus 5.5 060a8cced7 Pen resume: a stale draft prefers the path it came from over the selected one
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:48:31 -06:00
Hector VillarrealandClaude Opus 5.5 c642bfbe64 Pen resume: undo that drops a resumed draft clears its ⌘ selection
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:39:40 -06:00
Ashsajal be9f0c250a File Info: print a slow shutter speed as the fraction a camera writes (#6958)
The `1/n s` test in `exposure_text` was relative to the reciprocal, so the
tolerance shrank as the exposure slowed. Under 1/50 s the reciprocal drops
below 50, which makes 1 % of it smaller than half a rounding step, and nearly
every fraction fell through to the decimal form: a stored 0.031714 s printed
as "0.03 s" (5.4 % off) and 0.0215 s as "0.02 s" (7 % off).

Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the
shown time within 5 % of the stored one and is the form a photographer
expects. At or above 1/10 s the existing near-whole rule is kept, so 0.5 s is
still "1/2 s" and 0.4 s still "0.4 s". The decimal fallback moves from two
decimals to three significant digits ("0.105 s", not "0.11 s").

The two fixtures from the report now read "1/32 s" and "1/47 s" through
`photocraft-cli run ... --cmd file.fileInfo`.
2026-10-19 12:35:45 +06:00
Hector VillarrealandClaude Opus 5.5 00697fb777 Move typed text with a drag from just outside it (#5956)
While typing, a press outside the text committed it. Photoshop moves the
text instead when the press is just outside it (measured: 35 pt off the
glyphs moves it, 48 pt commits), and typing carries on. A plain press
within 40 pt of the text frame now starts the existing type transform as
a move, and the cursor shows Move there; further out a press commits as
before, and a paragraph box's handles still resize it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:25:38 -06:00
denstu473 dfb84a686d ci: trigger checks with current contributor status 2026-10-19 08:25:00 +02:00
Hector VillarrealandClaude Opus 5.5 199b0a122c Pen resume: a stale draft joins its source path; undo stops at its anchors
A draft whose subpath changed meanwhile is added to the path it came
from even with nothing selected in the Paths panel, instead of replacing
the Work Path. Undoing points in a resumed draft never removes the
path's own anchors: past the added ones it drops the draft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:21:34 -06:00
denstu473 2d23eee9ed Merge upstream main and resolve gap documentation conflict 2026-10-19 08:11:44 +02:00
Hector VillarrealandClaude Opus 5.5 cd44042921 Pen resume: keep the subpath's operation, add a stale draft as a new subpath
A resumed subpath takes only the drawn anchors and closing, so its path
operation survives. The draft remembers its document and the subpath as
it was; if either changed before the commit, the drawing is added as a
new subpath instead of overwriting another one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:06:20 -06:00
Hector VillarrealandClaude Opus 5.5 f99e12ecbd Carry on an open path from its end with the Pen (#6593)
A Pen click on an endpoint of a finished open path started a new
subpath there; Photoshop carries on drawing that path. A press on the
first or last anchor of an open subpath of the selected (or work) path
now resumes it, reversed from its first anchor, and the commit replaces
that subpath in one step. Shape mode and a targeted vector mask, which
take new paths, are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:49:40 -06:00
Hector VillarrealandClaude Opus 5.5 0c27aea23a Open the selection menu on a Quick Selection right-click (#7268)
A right-click with the Quick Selection tool opened the Brush Preset
picker; Photoshop opens the selection menu, as for the other selection
tools. The canvas menu now covers Quick Selection, and the right-click
opens the picker only for tools without one. The options-bar brush chip
still opens the picker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:12:13 -06:00
Hector VillarrealandClaude Opus 5.5 a42e831698 Map the bevel's shading through its gloss contour, not its light (#7360)
The gloss contour was applied to the highlight and shadow amounts after
the lighting split, so a contour that starts high (Rolling Slope -
Descending) turned every unlit pixel into a full highlight: a bevelled
glyph's flat face painted (255, 40, 42) over its (151, 22, 23) fill. It
now maps the shading before the split, with the flat face's mapped
shading as the neutral, on the CPU and the GPU. Linear is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:01:11 -06:00
Hector VillarrealandClaude Opus 5.5 5a5de0e73d Show Technique and Range in the glow Layer Style pages (#6698)
Inner Glow lacked Photoshop's Technique and Range, Outer Glow its
Technique, though the engine takes both. The pages now list them in
Photoshop 2025's order (Technique before the spread or choke, Range
after the contour), the dialog loads a glow's technique so OK keeps it,
and both glows' defaults carry Photoshop's factory technique and range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:46:14 -06:00
wwewtech afc817d1fa test(engine): a one-shot failure does not spend the counted failpoint 2026-10-19 07:51:51 +04:00
Hector VillarrealandClaude Opus 5.5 8caa09c657 Test Fade on a channel after a background filter and a tab switch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:23:30 -06:00
Diego Chagas f2a487b088 Flatpak: a second launch hands its files to the running PhotoCraft over a Unix socket (#1540)
#3755's hand-off listens on a loopback port, and every `flatpak run` gets
its own network namespace, so inside the sandbox the second launch could
never reach the first and opened a separate window. When FLATPAK_ID and
XDG_RUNTIME_DIR are set the primary now listens on
$XDG_RUNTIME_DIR/app/<app id>/instance.sock (0600), a directory every
sandbox of the app shares, and records "socket" instead of "port" in
instance.json. The lock, token, client cap, deadlines and reply are
unchanged; a crashed primary's socket file is unlinked under the lock,
and shutdown removes the socket.
2026-10-19 00:19:00 -03:00
Hector VillarrealandClaude Opus 5.5 a8e64ee938 Take Fade's channel target only from commands routed to channels
image.autoTone accepts a target param but edits the active layer, so an
explicit channel target recorded the unchanged channel for Fade. Only
commands channel_cmds::routed sends to channels now record one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:13:39 -06:00
Hector VillarrealandClaude Opus 5.5 5470e2fa0a Fade the target the command ran on, not the Channels panel's
An explicit target (edit.fill with "target": "pixels" while an alpha
channel is targeted) edits the layer, but Fade recorded the channel from
the Channels panel. after_command now resolves the target as the command
did, with inject_target: the explicit target, else the panel's, and only
for the commands it routes to channels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:59:57 -06:00
Hector VillarrealandClaude Opus 5.5 d1003c6191 Fade the alpha channel or Quick Mask an edit changed (#7382)
FadeSource only knew a layer and whether its mask was edited. With an
alpha channel or the Quick Mask targeted, filters, adjustments and paint
change that channel, but Fade blended the untouched layer back and left
the channel as edited. It now records what the command changed, as a
FadeTarget (a layer's pixels or mask, an alpha channel, or the Quick
Mask), and fades that surface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:43:36 -06:00
Cl4VD3andClaude Opus 5.5 89a34d0d15 Brush stepping: group like the panel, skip names it can't pick, test a refused step changes nothing
Review: presets with no group share the panel's "My Brushes" group (UNGROUPED now lives in the engine, and brush_panel uses it); a preset whose name only differs in case from an earlier one can't be selected by name, so stepping skips it instead of sticking; the empty-library test checks the brush and current preset are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:26:42 +02:00
jonathanwxh-cellandClaude Opus 5.5 2cca4faba0 Play .atn steps that switch to another open document
Photoshop records a switch to another open document as a `select` of
`Dcmn` by offset (-1 previous, 1 next). Import it as
`document.activate {"offset": n}`, the tab that many places from the
active one, and save it back as the same reference. Past the first or
last tab is refused rather than wrapped (#7103).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:22:57 +08:00
Gustavo Ziger 7cdfb250b9 fix: align Navigator viewport with animated camera 2026-10-18 23:19:13 -03:00
Gustavo Ziger bffa2cc783 fix: cancel animation for Navigator rectangle zoom 2026-10-18 23:05:59 -03:00
Gustavo Ziger e14dda9dc8 Merge main into feat/zoom-animation-566 2026-10-18 22:52:16 -03:00
heojunfoandClaude Opus 5.5 64e7ed1583 Studio: the floating Properties card has the Adjustments tab (#7265)
Window › Adjustments already selects Properties group tab 1, but Studio
themes float Properties as a card that only drew properties_body, so the
menu item (and the rail) opened the document's Properties and the
Adjustments grid could not be reached.

The card's title is now the group's pill tab strip, Properties |
Adjustments, as on the docked group: it follows and sets
dock_tabs.properties, honours hidden tabs, and its context menu closes a
tab or the card. The tab close logic moves out of the dock into
dock::close_tab so the card shares it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 10:43:28 +09:00
Gustavo Ziger a94c4e19e6 fix: prioritize both cameras during zoom redraws 2026-10-18 22:41:46 -03:00
Cl4VD3andClaude Opus 5.5 a9e7b48bf4 Previous / Next / First / Last Brush commands (, . ⇧, ⇧.)
Photoshop lists Previous Brush, Next Brush, First Brush and Last Brush in Edit › Keyboard Shortcuts
› Tools (`,` `.` `⇧,` `⇧.`); PhotoCraft had no way to step through brush presets from the keyboard.

`tools.previousBrush`, `tools.nextBrush`, `tools.firstBrush` and `tools.lastBrush` join the brush
key commands in `brush_key_cmds.rs`, so they can be rebound, recorded and driven by agents like
`[` / `]`. Each picks a preset with `tools.setBrush {preset}` (journaled, as a click in the Brushes
panel), walking the presets in the panel's order (groups by first appearance, then each group's
folder tree depth first, as `brushes_tab` draws them) and wrapping around; with no preset picked
yet, Next starts at the first and Previous at the last. No presets is an error, not a panic.

Tests: panel order with groups and nested folders; stepping, wrapping, first/last, the default
shortcuts, and the empty-library error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 03:41:39 +02:00
Ni-zav 0ee80f4bbb Merge remote-tracking branch 'upstream/main' into fix/3879-bevel-shading-controls-20261012 2026-10-19 07:36:07 +07:00
vangel76andClaude Sonnet 5.5 f3a727cc94 Merge upstream/main into fix/3490-move-align-bar
Resolve ATTRIBUTION.md: keep main's ellipse-dashed.svg entries and this PR's align-edge/distribute icon entries.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 02:28:09 +02:00
vangel76andClaude Sonnet 5.5 ce6f1eef0d Fix merge with main: load the contour library in load_presets_json; regenerate scorecard
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 02:21:11 +02:00
Ni-zavandVikram Bhaskaran a221622ad4 fix(compose): cover clipped Dissolve adjustments
Port the clipped-adjustment coverage path from #4711 and verify depth, alpha, masks, and document-coordinate GPU parity.

Co-authored-by: Vikram Bhaskaran <[email protected]>
2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy 2ef53bb673 test(gpu): cover clipped Dissolve opacity and fill parity 2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy 9050e86dac test(compose): preserve clipped Dissolve coverage across fill, boundaries, and regions 2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy d127cd6afb style(gpu): match rustfmt compact ColorOverlay initialization 2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy 157711d1be style(compose): apply rustfmt layout from CI for Dissolve regression 2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy 7e1c21e9a8 test(gpu): split Dissolve parity cases and exercise adjustment masking 2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy 21871dd40e refactor(gpu): branch explicitly on Dissolve effect-stack coverage 2026-10-19 07:18:54 +07:00
Nigel Ihza Firdausy 28807ed7ab test(gpu): cover Dissolve adjustment, clipping and effect parity 2026-10-19 07:18:53 +07:00
Nigel Ihza Firdausy 61724b49f0 fix(gpu): match CPU Dissolve coverage for adjustment, clipping and effects 2026-10-19 07:18:53 +07:00
Nigel Ihza Firdausy e0d3d940c6 fix(compose): resolve Dissolve coverage across adjustment, effects, and clipping paths (#3050-#3052) 2026-10-19 07:18:53 +07:00
vangel76 19b473f3eb Merge remote-tracking branch 'upstream/main' into tmp-4392 2026-10-19 02:17:07 +02:00
00xJ0 7bb7819b00 Lost window device: close only once every unsaved document is saved for recovery, retrying every 2 s; also check the device on the screen-picker frame (#6464 review) 2026-10-18 17:15:13 -07:00
Hector VillarrealandClaude Opus 5.5 308385b0bf Lasso tools: the outline is solid until the selection is made (#7495)
Photoshop draws the Lasso's, Polygonal Lasso's and Magnetic Lasso's outline as
a thin solid line while it is being drawn, and marching ants only once the
selection is made. Ours used the animated ants from the first point.

- `tool_feedback::solid_path`: a 1 pt black line over a soft light edge,
  Photoshop's look on light pixels and still visible on dark ones.
- The freehand Lasso's drag, the Polygonal Lasso's sides and rubber band, and
  the Magnetic Lasso's traced border use it. Patch and Content-Aware Move
  drags (the same branch as the Lasso) keep their ants, and so does every
  committed selection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:07:04 -06:00
Omar Saidahmed 53c876fe20 document type_size 2026-10-18 20:02:48 -04:00
00xJ0 9e83b41f45 control-protocol: CPU rendering lasts the session only after a device loss (#6897 review) 2026-10-18 17:01:19 -07:00
Hector VillarrealandClaude Opus 5.5 afa8706139 Move tool: soft edges stay partly selected and move whole, as in Photoshop (#7538)
Checked against Photoshop 2025 on a 15 px feathered shape moved from a loose
marquee:

- Its selection after the move follows the edge's transparency: the marching
  ants run along the 50 % line and Quick Mask shows the edge partly selected
  (measured strength 0.10 / 0.29 / 0.42 / 0.64 / 0.81 where alpha is 0.05 /
  0.24 / 0.39 / 0.57 / 0.71). We had made those pixels fully selected.
- Moving it again, even after Deselect and Reselect, leaves nothing behind,
  though Delete with the same selection leaves a ghost: the Move tool lifts at
  most each pixel's alpha, not alpha × selection.

So the move cut lifts min(alpha, selection) of each pixel (feathered moves over
opaque pixels are unchanged: k lifted, 1 − k left), and the moved pixels'
selection is what they took. Moving them again lifts them whole, with no faint
outline, and their ants hug the soft edge's 50 % line.

Also: arrow nudges add up to one floating piece, so up then down was back at
no offset and the drop kept the loose marquee. A piece that moved and came back
now keeps the moved pixels' selection, in one step; a click still changes
nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:56:41 -06:00
Hector VillarrealandClaude Opus 5.5 01479be23a Toolbar: colour chips stay centred when the tools scroll (#7316)
In a window too short for the tools, the toolbar's tool column scrolls, and
egui widens the UI by the scroll bar. The colour chips (and Default and Switch
Colors with them) were centred in that wider UI, so they sat a bar's width
right of the tools: about 3 px in the Studio and Pro themes, more with
Classic's wider bar. They are centred in the column as it is before the tools
scroll now, as in a window tall enough not to scroll.

Tested in every theme, one and two columns, at 1x and 2x: the chips paint at
the same place in a 420 pt and a 1400 pt window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:41:16 -06:00
Brian TorresandClaude Sonnet 5.5 bad6c62c80 Raw: list the Nikon ColorSpace tag in the clean-room sources and bump the gaps status line (#5987)
The README's clean-room section names every maker-note tag the crate reads and
where it is documented; 0x001E is added, and the code comment now names
ExifTool's Nikon tag documentation as its source.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-18 16:37:23 -07:00
Brian TorresandClaude Sonnet 5.5 8d0c579f02 Raw: a Nikon maker note's ColorSpace sets the preview colour space (#5987)
embedded_preview_color_space only read the EXIF ColorSpace, but Nikon bodies
write none: they say Adobe RGB only in the maker note (tag 0x001E: 1 sRGB,
2 Adobe RGB). A Nikon NEF with Adobe RGB selected had its JPEG preview read as
sRGB by the colour fit.

When the EXIF does not state a colour space, the Nikon maker note's is used
(the EXIF still wins, a DNG still states nothing, other makers' maker notes
are not read). The maker-note locator in tiffep.rs is shared. A packed Nikon
NEF with the tag joins the hostile-input seeds, and rawinfo prints the stated
colour space.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-18 16:36:16 -07:00
dotCooCoo 89195d7f16 Save: ask before replacing a hard link to the file the dialog named
A save whose added or lowercased extension names another entry in the
folder now asks before replacing it, even when that entry is a hard
link to the file the save dialog asked about. Two spellings of one
name on a file system that ignores letter case still count as the same
entry and do not ask.
2026-10-18 16:35:59 -07:00
Omar Saidahmed fdb1794014 share hsb conversions between photocraft-color and the color picker 2026-10-18 19:21:31 -04:00
Stepan Pavlenko a9870e6364 Merge remote-tracking branch 'upstream/main' into i18n/ru-dynamic-labels
# Conflicts:
#	crates/ui-egui/src/i18n/ru.tsv
#	docs/gaps.md
2026-10-19 02:19:03 +03:00
Omar Saidahmed 0814a1999e size bigtiff long8, slong8 and ifd8 entries as 8 bytes 2026-10-18 19:13:42 -04:00
Stepan Pavlenko 1cf58f1019 Merge origin/i18n/ru-dynamic-labels into i18n/ru-dynamic-labels
The remote commit 759b68c2 and our e4bc5c25 are the same work at different
dates; the merged result is our fuller catalog (4,227 keys incl. the
shortcut-conflict dialog and the current GPU-memory notice wording). The one
remote-only row carried the superseded English wording no longer in the code,
so it is not kept. The docs keep our side (4,227 keys / 1,229 labels,
2026-10-18).
2026-10-19 01:59:33 +03:00
Hector VillarrealandClaude Opus 5.5 344b8e4be6 layer.translate: reject a non-boolean selection even with no offset (#7538)
Review follow-ups (CodeRabbit on #7574):

- A zero offset returned before `selection` was checked, so
  {"dx": 0, "dy": 0, "selection": "yes"} succeeded. It is validated first now,
  and the test checks a rejected call changes nothing.
- A test pins down a Move drag of several layers while a piece floats: the
  press drops the piece first, then the ants follow the drag from there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:53:34 -06:00
Hector VillarrealandClaude Opus 5.5 df94c0442b Move tool: moving several layers carries the selection with them (#7538)
With several layers selected their selected pixels can't float, so the Move
tool moves the layers whole; the marquee stayed behind. Photoshop moves it with
them: the ants follow the drag, and the layers and selection land in one Move
step.

- Engine: `layer.translate` takes `"selection": true` to move the selection by
  the same offset in the same step.
- UI: the Move tool passes it when several layers are selected and there is a
  selection, and the ants follow the drag live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:32:21 -06:00
Higgs32584andClaude Opus 5.5 70eab0c230 Actions: stop when a Save step's document is closed by a Save Document script (#7367)
An imported Close with Save: Yes plays as `file.save` then `file.close` with no document.
A Save Document script event that closed the saved document left another document active,
and the Close then closed that one, unsaved edits and all. Playback now fails the Save step
when the document it saved is no longer open, so no later step acts on a fallback document.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:24:40 -04:00
Higgs32584andClaude Opus 5.5 bd5e5ed781 Artboards: move egui::Rect import into the tests (unused-import Clippy error)
Since pasteboard_quads returns [Pos2; 4], only mod tests uses egui::Rect,
so the non-test build failed clippy -D warnings with an unused import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:24:39 -04:00
Finn NewickandClaude Opus 5.5 6b4acf299f fix(atn): refuse strict export of canvas-bound selection modifies and placed pattern buckets
Feather, Contract and Smooth (and Expand) carry only their radius, so
applyAtCanvasBounds was dropped (#7540, #7541, #7542); the event table
gains an uncarried: list of such flags. A pattern Paint Bucket's Pttn
names no pattern and holds no placement, so an explicit pattern, scale,
angle or phase replayed as the current pattern at its defaults (#7543,
#7544, #7545, #7546). Those steps are now refused instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:14:12 +01:00
Finn NewickandClaude Opus 5.5 75626fb064 test(atn): use a named layer target in the carried Layer Properties case
A numeric target exports as the active layer (#7248, handled by #7262),
so it didn't show the carried form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:09:22 +01:00
Finn NewickandClaude Opus 5.5 897e1d744a fix(atn): keep a Transform Selection's interpolation; refuse what the transform descriptor can't carry
The importer read a transform's interpolation (Intr) only for a layer
target, so a Transform Selection replayed with the default resampler
(#7515). The export scaled and rotated about the centre without skew or
warp, so a step with another reference point, a skew, a warp or the
corners alias of quad exported as a plain scale; it is now refused
(#7516, #7517, #7518, #7519).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:07:52 +01:00
seotarek 49570a1678 test(atn): assert exported descriptor target references and check state invariance on rejections 2026-10-19 01:07:33 +03:00
Finn NewickandClaude Opus 5.5 1ec378bd02 fix(atn): refuse strict export of options the descriptors can't carry
Strict .atn export wrote these steps without one of their options, so
the imported step replayed differently. Neither side maps the options,
so the step is now refused (strict export fails, a partial export skips
it) instead of silently changing:

- New Layer: clipToPrevious, a colour label, fillNeutral, position below
  (#7502, #7512, #7513, #7514)
- New Document: a black or hex background, or backgroundColor (#7503)
- Load Selection: invert, or an add/subtract/intersect operation (#7506)
- Solid Color: a colour the opaque RGB descriptor can't hold (#7507)
- Layer Properties: keys other than layer, name, opacity, fill, blend,
  visible and color, such as locks, clipping or a layers list (#7196,
  #7246)
- Group Layers: an explicit layer target (#7236)
- any mapped 32-bit integer field outside i32, which was clamped (an Add
  Noise seed above i32::MAX replayed as i32::MAX, #7287)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:00:17 +01:00
seotarek 282b462a58 Merge remote-tracking branch 'upstream/main' into fix/atn-numeric-layer-ref-7248
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 00:39:12 +03:00
Hector VillarrealandClaude Opus 5.5 2dcc8788d8 Move tool: a floating piece's outline also decides presses and the shared frame (#7538)
Review follow-ups (CodeRabbit on #7574):

- A marquee or lasso press while a piece floats tested the selection it was
  cut with, so a press in the margin the cut left (where no ants are) dragged
  the piece. It tests the piece's outline now; there it starts a new selection.
- With several layers selected and the active one's pixels floating (a
  marquee Cmd-drag), Show Transform Controls shrank to that piece and dropped
  the other layers. The outline frames the piece only when it is framed alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:38:06 -06:00
jonathnwxh 86b4c67adf Expect post-commit best-effort preview cleanup on Unix 2026-10-19 05:37:19 +08:00
jonathnwxh 9c927993b5 Preserve omitted PCraft previews until manifest commit 2026-10-19 05:29:30 +08:00
Higgs32584andClaude Opus 5.5 8353ecc354 Merge upstream/main into fix/5712-artboard-pasteboard-hides-loose-layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:29:07 -04:00
00xJ0 e4d4ab4205 Presets: an Export As preset survives a damaged sibling set (test for #6906)
Extends a_damaged_preset_set_keeps_the_other_sets (#5602) to save an Export As preset, check it survives each damaged sibling set, and damage export_presets itself. Fails with export_presets: None in load_presets_json (the build break #6906 fixed), passes on main. Test suggested by dotCooCoo in #6906.
2026-10-18 14:28:21 -07:00
seotarek 09ff237798 Merge remote-tracking branch 'upstream/main' into fix/atn-numeric-layer-ref-7248
# Conflicts:
#	crates/engine/src/atn_cmds/tests.rs
2026-10-19 00:23:42 +03:00
Hector VillarrealandClaude Opus 5.5 cb85f77f5a Move tool: moving pixels again leaves no faint outline behind (#7538)
The shrunk selection took the moved pixels' alpha as its coverage, so
anti-aliased edges were only partly selected. Once the move was dropped
(changing layers, say), moving them again lifted a × a of each edge pixel and
left a × (1 − a) behind: up to 25 %, a faint outline where the shape had been.

The selection now keeps its own strength wherever the layer has pixels, so a
later move lifts edge pixels whole. Tested at 8, 16 and 32 bit: nothing is left
behind and the shape's total alpha is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:20:43 -06:00
Hector VillarrealandClaude Opus 5.5 5c7ca36f07 Move tool: transform controls and snapping frame the moved pixels too (#7538)
After a drag of selected pixels, Show Transform Controls still framed the
selection they were cut with, shifted by the drag, while the ants hugged the
pixels. Photoshop frames the pixels.

- The box frames a floating piece's outline (`transform_controls_rect`).
- Snapping starts before the press cuts the pixels, so the cut now retargets
  the move's box to their outline (`snap_ui::follow_cut`), for the Move tool
  and a selection tool's Cmd-drag. This replaces an estimate from the layer's
  bounds, which failed when the layer held other shapes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:20:43 -06:00
Hector VillarrealandClaude Opus 5.5 987753e76e Move tool: moving selected pixels shrinks the selection to them, as in Photoshop (#7538)
A marquee drawn loosely around an object on a transparent layer kept its
rectangle when the Move tool dragged the pixels. Photoshop shrinks the
marching ants to the pixels being moved as the drag starts: the selection
times the layer's transparency.

- Engine: the floating piece keeps its outline (`CutParts::outline`, the
  piece's alpha), and `select.drop` makes it the new selection. A mask,
  which has no transparency, and an empty piece keep the selection's shape.
- Canvas: a floating piece's ants follow that outline during the drag.
- Snapping and the move box use the moved pixels' bounds.

Tests at 8, 16 and 32 bit, Alt-drag copies, soft selections over
half-transparent pixels, the Background, a mask target and an empty piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:20:43 -06:00
Finn NewickandClaude Opus 5.5 9a167b2e88 test(engine): a refused tiffCompression leaves the document as it was
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:19:29 +01:00
Finn NewickandClaude Opus 5.5 e795cabc79 test(format): skip the bundle-lock test only where file locks are unsupported
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:16:37 +01:00
seotarek 7e89c8e816 fix(atn): carry pre-job layer selection and test explicit-null export 2026-10-19 00:06:39 +03:00
Korg1ch 2a9a35f038 test(menus): Ctrl+Alt+I then Alt up leaves the bar alone and Enter confirms Image Size (#7481) 2026-10-19 00:04:38 +03:00
Korg1ch 8c6166849a fix(menus): Alt held through a shortcut no longer puts the keyboard on the menu bar, so Enter reaches the dialog (#7481) 2026-10-19 00:04:38 +03:00
Finn Newick 9e0d59576b Merge remote-tracking branch 'origin/main' into fix/bundle-save-lock 2026-10-18 22:01:04 +01:00
Finn NewickandClaude Opus 5.5 3c24fec507 fix(tiff): leave alpha channels to #7415, keep the tiffCompression option
#7415 writes and reads every TIFF alpha and spot channel with Photoshop's
channel resources, so this branch's single-alpha-channel handling would
only make the two rework the same decode and encode paths. The codec,
io, test and doc changes for it go back to main's versions; the
tiffCompression save parameter, the CLI --tiff-compression flag and their
test stay, and the format-parity TIFF row names the option.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:57:49 +01:00
Codex 84df1468d8 launch args: keep the mut off macOS builds
CodeRabbit on #7242: the LaunchArgs destructuring asked for a mutable
launch_options, but the only mutation happens inside the
cfg(not(target_os = "macos")) block, so a macOS build warns about an unused
mut. The binding is made mutable inside that block instead, where the fold
happens. No behaviour change on any platform; upstream CI has no macOS leg,
so this is warning hygiene, not a CI fix.
2026-10-18 13:55:31 -07:00
Gustavo Ziger 1334534e10 Merge main into feat/zoom-animation-566 2026-10-18 17:49:07 -03:00
seotarek 5dedcd5d5f test(atn): cover showLayers and layer.select in numeric layer rejection 2026-10-18 23:48:21 +03:00
Finn Newick 6350be812f Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	docs/gaps.md
2026-10-18 21:41:19 +01:00
seotarek 5b757332d4 test(atn): cover numeric layer rejection for rename, label, and arrange commands 2026-10-18 23:28:50 +03:00
Finn Newick 33479efc04 Merge remote-tracking branch 'origin/main' into feat/system-info-graphics-memory 2026-10-18 21:24:00 +01:00
Finn Newick 5c665ca96f Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	crates/engine/src/file_cmds.rs
2026-10-18 21:23:58 +01:00
Finn Newick 1e8dcb53b9 Merge remote-tracking branch 'origin/main' into fix/bundle-save-lock 2026-10-18 21:21:28 +01:00
Finn Newick 97ec9e2260 Merge remote-tracking branch 'origin/main' into feat/system-info-graphics-memory 2026-10-18 21:21:23 +01:00
Codex a1c9743eac launch args: the environment overrides fold into launch_options again
#6499 moved the argument parsing into parse_launch_args, and main() now destructures the result without mut, so the line that folds the PHOTOCRAFT_* overrides into launch_options fails with E0596. One mut on that field; nothing else in the change moves.
2026-10-18 13:09:47 -07:00
UnemarsandClaude Fable 5.1 6c74253436 Camera Raw open: the dialog starts from the demosaic method the open developed with
The open job captures the demosaic preference when it starts; the dialog is queued when the job lands and read the preference again, so a `rawDefaults.demosaic` change in between gave `RawOpen::developed_with` the newer method, and Open with the dialog unchanged skipped the re-develop although the document had been developed otherwise (CodeRabbit on #7417).

The default open's note already says which method it used ("developed with default settings (ahd demosaic, …)"); `photocraft_io::raw::developed_demosaic(warnings)` reads it back and `queue_open_dialog` takes the method from there, the preference only as a fallback.

Tests: `the_developed_note_says_which_demosaic_method_was_used` (io), and `the_demosaic_method_is_chosen_at_open` now changes the preference between the open and the queued dialog.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 22:09:36 +02:00
Codex e2fb9ee87f Merge upstream main (9185b64b) into upfix/env-launch-options 2026-10-18 12:56:52 -07:00
00xJ0 73123e621a GPU fallback: Retry GPU after an error rebuilds the GPU canvas now, no restart (#6577)
After an error on a device that still works (out of memory, validation), Retry GPU builds a new GPU canvas on the same render state with a fresh health flag, and the views go back to the GPU. After a loss or a failed GPU start it still schedules the retry for the next launch. The dialog says which. Two new strings, translated in all 20 catalogs.
2026-10-18 12:53:50 -07:00
00xJ0 4615a8bc26 Survive a lost window GPU device: draw nothing, save unsaved work for recovery, close (#3438, #1215)
When the window's own wgpu device was lost (a driver reset, e.g. after sleep), egui-wgpu could
not draw another frame: a frame with shapes panicked inside it ("Failed to create staging buffer
for index data", #1215, exit 101, unsaved work lost), and a frozen window's close button opened
an unsaved-changes prompt nobody could see (#3438). The #243 fallback only covered the canvas.

- DeviceHealth::device_lost (wgpu reported the loss, not a recorded fault) and ::probe (a
  non-blocking poll that keeps running after a recorded fault).
- RecoveryStore::flush: finish queued autosaves and wait for them.
- gpu_status::check_window, at the end of every frame: once the window's device is lost the
  frame draws nothing; the first time every unsaved document is written to crash recovery
  (Services::flush_autosaves) and the app closes without asking. The next launch reopens them.
- The app watches the window device on the CPU canvas too; ui.gpu.simulateLoss {destroy: true}
  destroys the real device to drive the path.

Verified on an RX 6900 XT, Vulkan and DX12: destroying the device in the running app exited 101
with the egui-wgpu panic before, and now exits 0 with the document recovered on the next launch.
2026-10-18 12:53:48 -07:00
Higgs32584andClaude Opus 5.5 4ea30f3673 Background redraw: a kept composite holds its state weakly
Kept held an Arc<Document>, so up to KEEP old states (and every tile only they used) stayed
allocated outside Memory Usage after History dropped them. It now holds a Weak: a state still in
History (undo/redo, the eye toggled back) is found as before, by pointer or by equality with the
upgraded state, and an entry whose state is gone is dead and dropped at the next trim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:53:13 -04:00
Higgs32584andClaude Opus 5.5 4527b6a7e2 New Document: presets keep the Color Profile; a stale profile is cleared
- DocumentSettings gains an optional `profile`, so document.presets.save and Recent keep the
  Color Profile and command_params gives it back to file.new. Older presets load as the
  working space.
- Applying a saved preset drops the profile picked before, and a profile that doesn't suit the
  mode is removed from the dialog state, so Create never sends it.
- The rejected-profile test also checks the active document and its history are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:19 -04:00
Higgs32584andClaude Opus 5.5 8138fc12e7 test(web): a press, release and press in one frame keep all three events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:18 -04:00
Higgs32584andClaude Opus 5.5 56e8532c86 Actions: Select and Mask with Sample All Layers exports as smartBrushWorkspace
Export picked refineSelectionEdge, whose fields have no Sample All Layers, so the value was
dropped and re-import fell back to false. A step that sets it now exports as
smartBrushWorkspace, which keeps it; other steps still export as refineSelectionEdge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:17 -04:00
seotarek 6b16152c80 test(atn): assert session actions and doc revision unchanged on rejected save 2026-10-18 21:42:49 +03:00
Khawar Zamman WaniandClaude Opus 5.5 4e5f4ca617 Marching ants in device pixels, like Photoshop's (#7489)
The ants were snapped to the middle of an egui point and drawn one point
wide without antialiasing. At a fractional display scale (125 %, 150 %) a
point is not a whole number of pixels, so one edge of a selection came out
2 px thick and the next 1 px, and dashes were 5 px here and 4 px there.

New ants.rs draws every ants outline (committed selection, marquee/lasso/
zoom/move-box drag previews, Free Transform, Content-Aware Move) in device
pixels: every edge ceil(ppp) px thick, dashes and gaps 4x the thickness,
a screen-fixed pattern whose rows shift against each other so dash ends
slant as in Photoshop, and an unchanged march speed. Straight edges are
pixel-aligned mesh quads cut to the view; slanted edges stay antialiased
line shapes at the same thickness.

Tests: ants::tests (unit), tests/marching_ants_pixels.rs (GPU render at
1/1.25/1.5/2x; on the old code it fails with top 1 px, right 2 px).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:11:41 +05:30
seotarek d90346d148 fix(atn): capture pre-command selection so recorded sole layer delete omits numeric id (#7248) 2026-10-18 21:34:53 +03:00
Higgs32584andClaude Opus 5.5 ecc8fb4622 HEIF: tag a BT.709 image with a linear transfer as linear sRGB
cicp_profile left every integer BT.709 image untagged (read as sRGB), including transfer 8
(linear), which cicp_rgb already maps to linear sRGB. Only the SDR transfers stay untagged now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:34:20 -04:00
LosminekandClaude Opus 5.5 19ccfa9004 i18n(cs): bring the Czech catalog up to date
Add 127 labels that the interface looks up through variables and that
cs.tsv was missing (live keys only, checked against the code), update one
hint to quote the translated option, and fix the capitalisation of five
existing entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:12:34 +02:00
seotarek 1876a3e585 fix(atn): normalise recorded active layer to target form and expand refusal tests (#7248) 2026-10-18 21:04:36 +03:00
Marco Arnulfo 08a7ff622a Type options: the "T" icon shows its "Font size" tooltip (#6437)
The size field's drag area is laid over the icon after it, so egui hovered the
area and never the icon. scrub_option_icon hands the tooltip to that area.
2026-10-18 18:15:04 +02:00
Ni-zav 7240a69db1 merge: integrate queued screen capture cancellation with current main 2026-10-18 23:06:23 +07:00
Ni-zav fa5aac84c3 merge: preserve drag flyouts and upstream tool memory 2026-10-18 23:06:19 +07:00
Ni-zav cd33d45d0e merge: preserve bevel shading and upstream Technique controls 2026-10-18 23:06:15 +07:00
Ni-zav 642baa07c5 fix(screen-picker): keep capture preparation invisible 2026-10-18 23:01:58 +07:00
dotCooCoo 77e20dad31 Merge main into fix/save-added-extension-asks-before-replacing 2026-10-18 08:52:16 -07:00
abc2cbandClaude Opus 5.5 2a81d271b5 Merge upstream/main 4df773e9 into codex/github-smart-filters-usability-20261011
Conflicts and how they were resolved:
- crates/ui-egui/src/canvas.rs (ensure_filter_preview): kept our edit_command(), which
  also covers the Blending Options dialog and checks the target guard; main's
  smart_ui::confirm_command(fields, cmd, params) form (#6324) is the same preview
  (setParams on the proxy) without the guard.
- crates/ui-egui/src/filter_dialog.rs (preview_document_for_target): kept our scaling
  of the smart filters' lengths and the mask feather on the proxy (proxy_document
  already scales cache, placement and mask, so nothing is scaled twice); dropped
  main's run-at-full-size branch for setParams and its now unused
  SMART_FILTER_SET_PARAMS constant.
- crates/ui-egui/src/smart_ui.rs (open_editor): kept our open_editor (target guard,
  adjust_dialog then filter_dialog with reset_fields, then __smartFilter and the
  stored params) over main's edit_filter helper; adjustment edits keep their live
  preview (adjust_preview skips the layer path for __smartFilter), so main's
  __preview removal for __adjust is not needed.

Adapted to main's API: smart_ui::preview_command (main, #6324) now also returns the
Blending Options command for our blending dialog, so the preview window matches its
key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:52:02 +03:00
abc2cb f667f8204e Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-18 18:47:20 +03:00
abc2cb 90992f3f84 Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-18 18:47:20 +03:00
abc2cbandClaude Opus 5.5 957e68f600 Expand New Effects follows the destination of Transfer Effects
layer.layerStyle.transferEffects names the layer that receives the effects in
"to"; it has no "layer" param, so the disclosure target fell back to the active
layer and the wrong layer's effects were expanded or collapsed. Resolve "to"
for that command before recording new_fx_target. Every other Layer Style
command keeps resolving "layer" as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:47:20 +03:00
abc2cbandClaude Opus 5.5 4c0335612e Merge remote-tracking branch 'upstream/main' into codex/fill-mask-preview-20261012
Conflict in crates/engine/src/commands.rs (layer.newFillLayer.solidColor):
main now reads the colour with the checked color_param(cmd, p, key, default)?,
and this branch reads the Use Default Masks on Fill Layers option next to it.
Kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:46:18 +03:00
Khawar Zamman WaniandClaude Opus 5.5 9eef6aa51e same_file: compare file identity on Windows, so a hard link is the same file
On Windows `same_file` compared canonical path strings, so a hard link to
the input (another name for the same file) passed the new convert guard
without --in-place. It now compares the volume serial number and file index
(the `same-file` crate, already in the dependency tree; std's file_index is
unstable), as unix compares dev and inode, falling back to the
case-insensitive canonical path when a file won't open. The engine's
same_file test now checks hard links on every platform. (Review on #7454.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:13:06 +05:30
Khawar Zamman WaniandClaude Opus 5.5 4f0876958d CLI convert: refuse to write over the input file unless --in-place (#7437)
`photocraft-cli convert in.jpg in.jpg` re-encoded the original in place and
exited 0, so one wrong output template destroyed the source (a lossy JPEG
re-encode can't be undone). `batch` already refuses an --out folder that is
its --in folder (#492); convert now applies the same rule: an <out> that is
the <in> file, however it is spelt (./, absolute, hard or symbolic link), is
refused before anything is read, and --in-place opts in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:04:44 +05:30
nrq6993-a11yandCursor 87ff8bbc66 Use a press menu for Color panel HSB/RGB mode (#7317).
The combo box was the nearest to the Color Lookup LUT picker in harness tests.

Co-authored-by: Cursor <[email protected]>
2026-10-18 18:29:08 +03:00
abc2cb f72c1d1688 Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012
# Conflicts:
#	crates/engine/src/layer_multi_cmds.rs
#	crates/ui-egui/src/layers_panel_ui.rs
2026-10-18 18:27:36 +03:00
abc2cb 6486ca76f4 Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-18 18:27:19 +03:00
Ufuk Efe bc2a4479bd Merge branch 'upstream/main' into fix/tr-corrections (sync upstream CI fixes) 2026-10-18 18:08:40 +03:00
abc2cbandClaude Opus 5.5 0e8840f157 Fill layers: document the functions and tests the PR adds or changes
CodeRabbit measured the docstring coverage of the PR at 78.75%, under its 80%
threshold. Add short doc comments to the functions #3975 adds or changes and
to the ones next to them, saying what each does and which behaviour each test
pins. The Layers panel functions of #3289 are left to that PR. Comments only;
no code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:55:32 +03:00
abc2cbandClaude Opus 5.5 a83ec3d2c9 Layers panel options: document the functions next to the ones the PR changes
CodeRabbit counts the functions around the lines a PR changes, not only the
changed ones, so the PR's docstring coverage stays under its 80% threshold
unless the untouched neighbours are documented too. Comments only; no code
changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:54:54 +03:00
abc2cbandClaude Opus 5.5 25b79243f1 Smart Filters: document the functions next to the ones the PR changes
CodeRabbit counts the functions around the lines a PR changes, not only the
changed ones, so the PR's docstring coverage stays low (59.49%, threshold 80%)
unless the untouched neighbours are documented too. Comments only; no code
changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:54:43 +03:00
abc2cbandClaude Opus 5.5 79793acefe Smart Filters: document the functions and tests the PR adds
CodeRabbit measured the docstring coverage of the PR at 59.49%, under its 80%
threshold. Add short doc comments saying what each helper does and which
behaviour each test pins. Comments only; no code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:50:28 +03:00
abc2cbandClaude Opus 5.5 f7942efd1d Smart Filter editor starts missing parameters from the spec defaults
open_editor opened the filter's dialog and copied the recorded parameters over
its fields. filter_dialog::open starts from the user's last-used values, so a
recorded filter that lacked a key (an older file, or a command that gained a
parameter) showed, and on OK saved, the remembered value instead of the
default. Reset the freshly opened filter dialog to the schema defaults, as
Alt+Cancel does, before the recorded parameters are copied in. Adjustment
editors are unchanged, and a plain Filter menu dialog still remembers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:49:38 +03:00
Korg1ch ccdf082c62 test(caf): erased sampling area previews as an error; a failed OK keeps the workspace open (#7384) 2026-10-18 17:49:09 +03:00
Korg1ch f0ef89baf6 fix(caf): an erased sampling area previews as an error, and a failed OK keeps the workspace open (#7384) 2026-10-18 17:48:53 +03:00
abc2cbandClaude Opus 5.5 7a671f1486 Layers panel options: document the functions and tests the PR adds
CodeRabbit measured the docstring coverage of the PR at 79.41%, under its 80%
threshold. Add short doc comments saying what each helper does and which
Photoshop behaviour each test pins. Comments only; no code changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:41:58 +03:00
abc2cbandClaude Opus 5.5 792413b528 Expand New Effects follows the layer a Layer Style command changes
The disclosure target only read a numeric "layer" param, so a layer named by
its name (which every command accepts through explicit_layer) fell back to the
active layer and the wrong layer's effects were expanded or collapsed. Resolve
the target with commands::layer_param, which understands every explicit form
and still falls back to the active layer. layer.layerStyle.clear needs no case:
it leaves the layer without effects, which the existing post-run check already
excludes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:40:49 +03:00
abc2cbandClaude Opus 5.5 62dbd6ced7 Layers panel: thumbnails off no longer leave mask thumbnails that catch drags
starts_mask_drag reads the mask thumbnail rects recorded in egui memory on an
earlier frame. With the thumbnail size set to None no mask thumbnail is drawn
and nothing is recorded, so rects from when thumbnails were on stayed behind and
a drag of the row that started there was classified as a mask drag. Forget the
layer's recorded rects before the drag is classified when thumbnails are off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:40:48 +03:00
abc2cbandClaude Opus 5.5 3f5c65e166 Workspace layouts no longer save or restore the Layers panel options
The options persist on their own in the preferences (persist_options and
restore_options, as Photoshop's Panel Options do), and engine commands read
them from there. Restoring a workspace layout that carried the key put the UI
out of step with the preferences, so the panel and the commands disagreed.
Drop layersPanelOptions from the layout snapshot and from apply; layouts saved
by earlier builds keep the key, which is now ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:40:48 +03:00
UnemarsandClaude Fable 5.1 8909e1c948 Camera Raw: the full-resolution preview of a raw open shows the raw-stage sliders
When a raw opens, Shadows, Highlights, Clarity, Texture and Dehaze act on the sensor data (`retone`, in linear light before the camera's tone curve) and Open develops the file that way, but the dialog's full-resolution preview ran the filter's RGB versions of those sliders: at 100 % the preview showed one thing and Open made another.

`DetailPreview` gets the raw's tone curve (`raw_curve`, set when the open-time dialog opens); with it, `develop` retones the whole area from the whole frame's maps exactly as Open does, then runs the rest of the pipeline with those five sliders at zero. Without a curve (the filter on a layer) nothing changes. `retone` runs its rows in parallel (`crate::par`) and recomputes the linear colours per band instead of keeping a second copy of the image, since the detail preview develops 16–45 MP frames.

Test: `a_raw_open_refines_raw_stage_sliders_from_the_whole_frame`.

Split out of #3816 at the reviewer's request.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 16:38:58 +02:00
Higgs32584andClaude Opus 5.5 109c34b273 Auto-Blend: cap the work area past the canvas independently of layer bounds
A sparse layer (a dot at each far corner) has huge bounds that also raise
the content estimate, so the whole union was chosen and its buffers could
exhaust memory. Past the canvas, work on at most MAX_BLEND_PIXELS; beyond
that keep to the canvas as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:30:07 -04:00
UnemarsandClaude Fable 5.1 357dfd1143 fmt: the test import closure
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 16:29:01 +02:00
UnemarsandClaude Fable 5.1 e4e0a789cc Camera Raw: choose the demosaic method when a raw opens
A camera raw always opened with AHD demosaicing; `photocraft-raw` has three methods but neither the open-time Camera Raw dialog nor Preferences offered them.

- Preferences › Camera Raw › Demosaic (`rawDefaults.demosaic`: ahd, mhc, bilinear; default ahd): raws open with it.
- The open-time dialog's Detail panel gets a Demosaic row that starts from the preference; Open re-develops when the choice differs from the method the file was opened with (`RawOpen::developed_with` / `demosaic`, `RawTuning::demosaic`), as it does for the raw-stage sliders. The preview keeps the method the file was opened with. Control channel: `ui.demosaic`, `openingRaw.demosaic`.
- Imports take `photocraft_io::ImportOptions` (SVG group depth and demosaic) instead of the depth alone: `import_with_options`, `import_with_options_and_interrupt`, `jobs::import_options(prefs)`, `ImportFn` takes `&ImportOptions`; desktop, web, background opens and Place pass the user's settings.
- The eight new strings in all 20 catalogs; the scorecard counts the new preference.

Test: `camera_raw_ui::tests::the_demosaic_method_is_chosen_at_open` (another method re-develops with different pixels; the preference develops the open itself and Open then changes nothing; unknown methods and the choice outside a raw open are refused); `prefs_ui` shows the setting.

Split out of #3816 at the reviewer's request.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 16:24:46 +02:00
Michael Higgins d90d4041df Merge branch 'main' into fix/5727-move-tool-offset-readout 2026-10-18 10:16:49 -04:00
Michael Higgins f43dac99f5 Merge branch 'main' into fix/5806-displace-asks-for-map 2026-10-18 10:16:44 -04:00
Michael Higgins bb3c446fbd Merge branch 'main' into fix/5703-pen-make-selection 2026-10-18 10:16:22 -04:00
Michael Higgins be9672619f Merge branch 'main' into fix/321-new-document-color-profile 2026-10-18 10:16:00 -04:00
Michael Higgins 27260461e1 Merge branch 'main' into fix/iris-xe-texel-guard 2026-10-18 10:15:37 -04:00
Michael Higgins 01e1f3f126 Merge branch 'main' into fix/6744-deselect-noop 2026-10-18 10:15:32 -04:00
Michael Higgins 7ce7a347f8 Merge branch 'main' into fix/6563-layer-texture-oom 2026-10-18 10:15:26 -04:00
Michael Higgins 95f5a19a0a Merge branch 'main' into fix/6752-heic-hlg 2026-10-18 10:15:08 -04:00
Michael Higgins 76ce0aa217 Merge branch 'main' into fix/6745-atn-event-mapping 2026-10-18 10:14:51 -04:00
Michael Higgins f1cfdf40f5 Merge branch 'main' into fix/6528-solo-restore-cache 2026-10-18 10:14:45 -04:00
Michael Higgins e9c1232ce7 Merge branch 'main' into fix/2095-android-menus 2026-10-18 10:14:40 -04:00
Michael Higgins 1158c79c87 Merge branch 'main' into fix/6865-selection-not-dirty 2026-10-18 10:14:30 -04:00
dotCooCoo 44d3944efa Merge main into fix/save-added-extension-asks-before-replacing 2026-10-18 07:10:35 -07:00
Michael Higgins 8b3621fa92 Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-18 10:04:43 -04:00
abc2cb 6690b8535d Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-18 17:03:16 +03:00
abc2cb d88d4a228d Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-18 17:03:15 +03:00
abc2cb 710c26af41 Merge remote-tracking branch 'upstream/main' into codex/github-smart-filters-usability-20261011
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-18 17:03:06 +03:00
abc2cb 66f91c3e25 Merge remote-tracking branch 'upstream/main' into feat/layers-panel-photoshop-options
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-18 17:03:06 +03:00
Higgs32584andClaude Opus 5.5 266273d50c Auto-Blend: a contained image on the left or top keeps its side of the seam
On equal cost the seam search took the lowest column/row, so an image wholly
inside the composite on its left (or top) got a seam on its own outer edge
and no pixels. Break ties away from such an image's side; test the left and
top cases too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:00:14 -04:00
Higgs32584andClaude Opus 5.5 c8f7cfaae6 Artboards: paint pasteboard pieces as quads so a rotated view keeps the holes (#5712)
pasteboard_rects returned each piece's screen bounding box, which in a rotated
view covers the loose layers (and boards) it was cut around. pasteboard_quads
returns each piece's four screen corners and the canvas paints them as meshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:45:11 -04:00
Higgs32584andClaude Opus 5.5 e8848e8ba2 Auto-Blend Layers: turn the Background into a layer before masking it
Refs storytold/PhotoCraft#5745, split out of #5816.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:32:05 -04:00
Higgs32584andClaude Opus 5.5 dfaf48e801 Auto-Blend Layers: Seamless Tones and Colors turns on gain compensation
Refs storytold/PhotoCraft#5745, split out of #5816.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:28:16 -04:00
Higgs32584andClaude Opus 5.5 2fc4360d67 Auto-Blend Layers: mask and blend the whole layers, past the canvas too
Refs storytold/PhotoCraft#5745, split out of #5816.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:24:35 -04:00
Higgs32584andClaude Opus 5.5 17c9cda345 Artboards: the Background and full-canvas layers keep the pasteboard (#5712)
Review on #6410: any visible layer covering the whole canvas (the default
white Background, or a fill layer whose bounds are unbounded) removed the
pasteboard entirely. Those are backdrop, not loose work: pasteboard_rects
now skips the document's Background and any layer whose composite bounds
are unbounded or contain the canvas. The test helper is back to the
default white document, and full_canvas_layers_keep_the_pasteboard covers
it. Merging main also drops the rustfmt-only change to
smart_filter_authorization.rs from the PR diff.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:19:39 -04:00
Higgs32584andClaude Opus 5.5 62ca145979 Auto-Blend: keep this PR to the seam fix for #5745; split the rest out
Per review, the canvas-wide area, Seamless gain compensation and Background
conversion move to their own PRs. Adds an engine test for the crop case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:18:37 -04:00
Reynold Hu 60636200ad ui: test that the closed picker's wheel keeps advancing while fonts become recent 2026-10-18 20:54:35 +08:00
Reynold Hu 2fb3e47c1e ui: the closed font picker's wheel keeps a stable order (review) 2026-10-18 20:54:35 +08:00
Reynold Hu 919ff3e0e9 ui: trim the recent fonts at once when the preference lowers the cap (review) 2026-10-18 20:54:35 +08:00
Khawar Zamman WaniandClaude Opus 5.5 17c26cb089 Value fields: a gesture that wheels off the field is the view's from then on (#5473)
A hovered field that owned a gesture kept it while the pointer wheeled
vertically off it: the view scrolled, and coming back over the field
in the same gesture stepped it again. Any wheel with the pointer off
the field now hands the rest of the gesture to the view, as a panel
gesture already keeps scrolling the panel over a field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:58:04 +05:30
Higgs32584 5a4d46d668 Merge remote-tracking branch 'upstream/main' into fix/5745-auto-blend 2026-10-18 08:24:49 -04:00
Higgs32584 f73df05a84 Merge remote-tracking branch 'upstream/main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-18 08:24:46 -04:00
Reynold Hu 82be3865a6 Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	crates/ui-egui/src/type_font_picker_tests.rs
#	docs/scorecard.md
2026-10-18 20:24:41 +08:00
wwewtech c6e4d8a945 test(engine): make the define-brush mask allocation fail on its own, fail-after-one (#212) 2026-10-18 16:08:40 +04:00
MarekSpodBiedry ac5b4cd56f Merge branch 'main' into issue-6810 2026-10-18 14:05:40 +02:00
Matthias Schlich 3192457c02 perf(smart-filters): find the object's bounds once per render
Render filters on a smart object asked for the placed stage each time. When the stage cache had
evicted it, every one of them placed (and decoded) the source again: four Lens Flares, four
decodes. The bounds are now found once and reused (CodeRabbit review on #6160).
2026-10-18 14:02:54 +02:00
Khawar Zamman WaniandClaude Opus 5.5 221adb3d67 Value fields: a sideways swipe off the field also hands the gesture to the view (#5473)
The hand-off from a5aa05f5 sat behind the hover check, so a sideways
swipe with the pointer off the field kept the gesture the field's;
coming back over it in the same gesture stepped it. The hand-off now
runs before the hover check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:30:23 +05:30
Khawar Zamman WaniandClaude Opus 5.5 a5aa05f54b Value fields: a sideways swipe after a zero-delta start pans the view (#5473)
A trackpad gesture that starts with a zero-delta event over a hovered
field let the field claim it; a plain sideways Move that followed was
not the field's, yet the field kept the gesture and zeroed the smoothed
scroll, so the view could not pan. Wheel the field doesn't take now
hands the rest of the gesture to the view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:11:55 +05:30
Vikram Bhaskaran 119225b0d0 Merge main into pr/6498-comp-order.
Keep the Move Up and Move Down catalog rows after the strings main appended to the same end of each language file.
2026-10-18 21:53:51 +11:00
Khawar Zamman WaniandClaude Opus 5.5 9a834aefe8 Value fields: a trackpad gesture that starts with no delta still steps a hovered field (#5473)
macOS opens a trackpad gesture with a zero-delta wheel event. Since plain sideways scrolls
were left to the view, that event was too, so a field under the pointer never claimed the
gesture and the swipe didn't step it. An event with no delta at all is the field's again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:10:12 +05:30
Khawar Zamman WaniandClaude Opus 5.5 20385b7043 Test: a refused restore_layer_selection changes no selection or revision
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:52:34 +05:30
Khawar Zamman WaniandClaude Opus 5.5 87c53e06ab Value fields: leave the view its whole wheel, and sideways swipes (#5473)
While a field had focus, every frame without a wheel event zeroed egui's smoothed scroll,
cutting the tail off the ⌘/Ctrl + wheel the field leaves to the view (a 40 pt pan moved
36). Only the tail of a gesture the field took is dropped now. A plain sideways scroll (a
trackpad swipe) is the view's too; Shift + a sideways wheel still steps by ten.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:52:34 +05:30
Khawar Zamman WaniandClaude Opus 5.5 57519aee7d Hand tool: a middle-button pan keeps panning when ⌘ or ⌥ goes down (#6434)
With the Hand selected, ⌘ or ⌥ pressed during a middle-button drag turned it into the
temporary Zoom tool, which outranks the middle button's Hand, so the pan stopped. A held
middle button now keeps the Hand's modifiers from zooming.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:52:33 +05:30
MarekSpodBiedry ff5b1228c6 Tab in Full Screen Mode shows the menu bar and panels; leaving Full Screen restores them as before (#6760) 2026-10-18 11:31:53 +02:00
nrq6993-a11yandCursor 59bb90ad1b Fix rustfmt on Color dock match arm trailing comma.
Ubuntu CI runs cargo fmt --check; block arms must not use trailing commas.

Co-authored-by: Cursor <[email protected]>
2026-10-18 12:30:48 +03:00
nrq6993-a11yandCursor 416e546562 Format stroke_constraint call in lasso_ui for rustfmt.
Co-authored-by: Cursor <[email protected]>
2026-10-18 12:17:33 +03:00
Matthias Schlich 690a73b595 Merge origin/main into feat/psd-render-smart-filters 2026-10-18 11:14:58 +02:00
Elvis33LE d1ee15a189 ci: retrigger (portal_save timed out on ubuntu; 9193/9194 passed, branch is gpu-test-only) 2026-10-18 11:04:18 +02:00
Khawar Zamman Wani 5a68ee5d54 Merge origin/main into hand-tool-modifier-zoom 2026-10-18 14:27:55 +05:30
Khawar Zamman Wani 087e540c6b Merge origin/main into fix/5571-group-layers-collapsed 2026-10-18 14:27:54 +05:30
Khawar Zamman Wani 289ad2bb3e Merge origin/main into fix/pen-path-visible-while-panning-6592 2026-10-18 14:27:54 +05:30
Khawar Zamman Wani 1b286fb186 Merge origin/main into fix/control-port-zero-logs-bound-port 2026-10-18 14:27:54 +05:30
Khawar Zamman Wani 6ab5b734e1 Merge origin/main into fix/6483-type-commit-on-layer-change 2026-10-18 14:27:54 +05:30
Khawar Zamman Wani 12ccac5543 Merge origin/main into field-wheel-5473 2026-10-18 14:27:54 +05:30
nrq6993-a11yandCursor 89522c5859 Fix panel-toggle shortcut test after modifier-aware capture (#7190).
Press plain F5 in the F5-reassignment scenario so capture does not inherit Shift from unrelated key events; satisfy clippy on capture_key_press.

Co-authored-by: Cursor <[email protected]>
2026-10-18 11:53:23 +03:00
nrq6993-a11yandCursor 214b1a0710 Color panel: add RGB slider mode for accessibility (#7172)
Users can switch the Color panel from HSB to RGB sliders under the field, with control-channel support and tests.

Co-authored-by: Cursor <[email protected]>
2026-10-18 11:25:02 +03:00
nrq6993-a11yandCursor a2372a9ce2 Fix rustfmt on viewport_icon for CI format check.
Co-authored-by: Cursor <[email protected]>
2026-10-18 11:08:41 +03:00
Florent Siegenthaler 2a07a771e4 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks (the
"Contract" and "Duplicate Layer" rows main now has are dropped from the
branch).
2026-10-18 09:56:04 +02:00
Elvis33LE 459a25bd95 Revert "perf(gpu): batch resident overlap copies"
This reverts commit f57bb1f3b1.
2026-10-18 09:17:34 +02:00
nrq6993-a11yandCursor ab668a1dff Freeform Pen: constrain freehand drags with Shift (#7171)
Holding Shift while drawing a freeform path segment keeps points on a
straight 0°/45°/90° line, reusing the painting-tool stroke constraint.

Co-authored-by: Cursor <[email protected]>
2026-10-18 10:05:32 +03:00
nrq6993-a11yandCursor fc298ebf12 fix(ui): record Shift in Keyboard Shortcuts capture (storytold/photocraft#7190)
Track modifier keys across capture frames and walk events so ⇧/⌃/⌥ count even when egui reports no modifier bits on them.

Co-authored-by: Cursor <[email protected]>
2026-10-18 09:32:53 +03:00
langrain 94b0219f17 Fix: add missing Chinese translations for Up and To
This PR fixes the missing Chinese translations for "Up" (in the Direction panel) and "To" (in the Gradient Overlay panel).
2026-10-18 06:24:11 +00:00
nrq6993-a11yandCursor 4b88e94f91 macOS: keep bundled Dock icon after eframe loads (storytold/photocraft#7253)
Skipping with_icon on .app bundles let eframe substitute its default glyph once
the window opened; pass IconData::default() so the bundle .icns stays in the Dock.

Co-authored-by: Cursor <[email protected]>
2026-10-18 09:04:38 +03:00
nrq6993-a11yandCursor 33398aeb1c fix(ui): allow collapsing brush folders while searching (storytold/photocraft#7257)
Search no longer forces groups and nested folders open; collapsed state is honored during filter.

Co-authored-by: Cursor <[email protected]>
2026-10-18 08:44:16 +03:00
seotarek b484a2edb9 fix(atn): reject numeric layer references in .atn export (#7248) 2026-10-18 08:36:25 +03:00
ahmed0magdyandClaude Opus 5.5 781a842d89 test(io): the SVG text-transform test shapes with the bundled Inter (#4806)
transformed_text_lands_where_resvg_draws_it returned early, and passed
without checking anything, when the import found no font: it relied on
system fonts, and photocraft_text::CRAFT_FONTS is empty unless the build
sets CRAFT_FONTS_DIR. With the system fonts hidden it passed in 0.00 s.

It is now a unit test in src/svg.rs that parses through parse_with with
a font database holding only the bundled Inter Regular (always compiled
in), and renders its resvg reference from the same parse, so both sides
use the same face. An empty import is now a failure. It fails with the
#5334 fix reverted, and passes with the system fonts hidden.

Raised in review of #5334 by tanish2k09.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 05:23:35 +00:00
Codex d3d1506e13 single_instance: the menu override is on and off exactly as main.rs reads it 2026-10-17 20:19:02 -07:00
Codex b5fb2fde62 single_instance: each environment override is off the way it is read 2026-10-17 20:19:02 -07:00
Codex 5c48fa987d Save As on Linux: the one file type offered gives the name its extension
With a single type offered the portal reports no filter, and the dialog opens on the stem (services::dialog_file_name), so accepting the name as shown returned a bare path: Swatches became a file named "Swatches" instead of "Swatches.aco" for the Swatches and preset-export flows. The sole filter's extensions are now used when the answer names no type this dialog offered; several types with no pick still add nothing, and an extension the user typed stays their own (with_chosen_type keeps one it recognises).

Tests: the mock portal answers the bare stem for a single filter and gets back Swatches.aco; chosen_type is also checked directly, so the rule is covered where no dbus-daemon exists to run the portal test.
2026-10-17 19:24:29 -07:00
Codex a52f458702 Layer Export As: the dialog opens with the layer's size, not the document's
open seeds width and height from the document, and open_layer replaced only the internal __w/__h, so the dialog showed and edited the document's size while the export used the layer's trimmed pixels: a 10x6 layer in a 40x30 document opened at 40x30, and editing one side started from the wrong value. In scale mode the fields now follow the layer's trimmed size; a remembered exact pixel size stays, because that is a size the user asked for.

Test: a 10x6 layer in a 40x30 document opens at 10x6, a remembered 50 percent at 5x3, and remembered pixels are untouched.
2026-10-17 19:22:42 -07:00
Codex a465fd2b1c A second launch that sets only environment overrides keeps its own process
PHOTOCRAFT_PRESENT_MODE, PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS and the two automation roots are documented as the equivalent of the per-launch flags, and a per-launch flag makes a launch keep its own process. A launch that sets only variables left launch_options empty, so it handed its files to the running instance (or merely raised its window) and exited before any override was read. Those variables now count as the flags they stand for; empty and 0 mean off, as with PHOTOCRAFT_NEW_INSTANCE.

Test: each variable maps to its flag, a value that means off maps to nothing, and skip_reason names the flag.
2026-10-17 19:22:42 -07:00
ametadot c9fc1f3b2e test(camera-raw): keep identity tests after production source 2026-10-18 12:21:47 +11:00
Korg1ch cc3ca41c6d test(liquify): zoom keys step the preview, not the canvas; pinch and wheel zoom about the pointer (#7210) 2026-10-18 04:17:32 +03:00
Korg1ch 5ed2a1ff99 fix(liquify): zoom the preview with ⌘+/⌘−/⌘0/⌘1, a trackpad pinch and ×1.1 wheel notches, as in Photoshop (#7210) 2026-10-18 04:17:32 +03:00
Vikram Bhaskaran af2f5ad4b7 Merge origin/main into fix/clipboard-center-overflow 2026-10-18 11:56:41 +11:00
Vikram Bhaskaran c87af035b7 Merge origin/main into feat/iff-read 2026-10-18 11:56:31 +11:00
Vikram Bhaskaran 4a2a73c226 Merge origin/main into feat/dicom-read 2026-10-18 11:55:26 +11:00
Vikram Bhaskaran cf2d819fad Merge origin/main into fix/5258-psd-open 2026-10-18 11:54:46 +11:00
Vikram Bhaskaran 0397867a65 Add catalog entries for Move Up and Move Down (#6498).
every_tl_literal_is_translated requires every complete-menu language
to include the new Layer Comps context actions.
2026-10-18 11:47:04 +11:00
Korg1ch 3ee6620935 test(ui): cover the Adaptive Wide Angle constraint menu and the orient op 2026-10-18 03:00:09 +03:00
Korg1ch 7e5a97385e fix(ui): right-click an Adaptive Wide Angle constraint for its orientation menu, as in Photoshop 2026-10-18 03:00:09 +03:00
Korg1ch 00e4423fcc test(compose): Dissolve effects are speckled, tile-independent and match on the GPU (#6995) 2026-10-18 02:57:55 +03:00
Korg1ch e0d90b2896 fix(compose): layer effects in Dissolve mode speckle instead of painting as Normal (#6995) 2026-10-18 02:57:44 +03:00
dotCooCoo cfa5242098 Merge main into fix/liquify-smart-filter-reopens-workspace 2026-10-17 16:52:01 -07:00
dotCooCoo 53ced08b23 Liquify: an OK that cannot be applied returns the error
`liquify_ui::commit` discarded the results of
`layer.smartFilter.setParams` and `filter.liquify`. `PhotocraftApp::run`
showed a failed command's error in the status bar, but the control
channel answered `filter.liquify {"ui": {"commit": true}}` with
`{"committed": true}`. `commit` now returns the command's result, and
the control channel returns the error, as `gallery_ui::commit` does. The
dialog closes on OK whether or not the command succeeds, as the Filter
Gallery's does.

Two tests send OK through the control channel, one after the smart
filters are cleared and one after the layer is deleted. Each checks
that the dialog closed, the status bar shows the error, no history step
was added, and the control channel returned the error.
2026-10-17 16:45:04 -07:00
dotCooCoo 8af2ffef6e Merge main into fix/save-added-extension-asks-before-replacing 2026-10-17 16:28:33 -07:00
dotCooCoo b8727aff86 Merge main into fix/craft-fonts-register-under-manifest-name 2026-10-17 16:28:31 -07:00
Stepan Pavlenko e4bc5c25e8 i18n(ru): translate the dynamic labels the Russian catalog was missing
The Russian catalog passed `cargo xtask i18n-coverage` (100% of measured keys)
but lacked the runtime-only labels the other catalogs already carry, so they
showed in English: filter and adjustment parameter names and choices, gallery
names, Camera Raw panels (Balance/Clarity/Dehaze/Color Mixer/Texture/…),
History/Layers/paths/channels menu labels, metadata and File Info fields,
System Info and hardware status lines, rasterize prompts, composed status
templates and dialog text.

All of them are translated (1,229 keys added; the catalog is now 4,227 keys
against upstream's 2,998), written from the meaning of the English labels and
reusing the vocabulary the catalog already uses (Highlights → Света,
Midtones → Полутона, Feather → Растушёвка, Pattern → узор). Filter and gallery
names are aligned with the menu labels already in the catalog so a filter never
reads two ways (Texturizer → Текстуризатор, Unsharp Mask → Нерезкое
маскирование, Shape Blur → Размытие по фигуре, …).

Tests: `russian_covers_generated_filter_options_and_gallery_names`
(filter_dialog.rs, the Italian/Korean/Spanish check) and
`russian_dynamic_status_hardware_and_dialog_labels_are_translated` (i18n/mod.rs).
Docs: docs/localization-ru.md (terminology), localization-parity, gaps G13,
ROADMAP, ATTRIBUTION and LICENSE-translations.

Validated on a rebase onto upstream/main: `cargo test -p photocraft-ui-egui
--lib` 2,998 passed / 0 failed; clippy clean (the only lint is a pre-existing
`nonminimal_bool` in canvas.rs, untouched here and present on upstream main);
`xtask layers` OK; `xtask wasm` OK; `xtask i18n-coverage` ru 1,863 / 1,863;
offscreen `snapshot` in Russian checked.
2026-10-18 02:27:44 +03:00
ametadot 16e5374bcf fix(camera-raw): preserve document identity across queued completions 2026-10-18 09:53:58 +11:00
Theo Wilenius 6ebfd87a0d Merge branch 'main' into fix/6286-guide-drop-on-either-ruler 2026-10-18 00:59:29 +03:00
RehanKhatkarandClaude Opus 5.5 f94fb6cd59 Open EXR Parts: the menu asks for a file and shows the parts dialog (#7169)
File › Open EXR Parts… ran the engine command with no params, failing
with `missing "path"` and never showing a dialog: the picker and parts
form in view_cmds were never reached because the id was missing from
`wraps()`. Add it, with a regression test that drives the menu through
the file picker, the parts dialog and OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:37:26 +05:30
MarekSpodBiedry 237ff1d01f Merge branch 'main' into issue-6810 2026-10-17 20:21:03 +02:00
Nicolas FalesyandClaude Opus 5.5 c05d827fde perf(type): refresh type layers without cloning the document (#7092)
`type_cmds::refresh` read only the document's resolution and pixel format,
but took `&Document`, so 15 call sites that hold the layer mutably cloned the
whole document to pass it, once per keystroke and pointer move. The new
`refresh_at(dpi, format, t)` takes the two values; those sites read them
before borrowing the layer. `refresh(doc, t)` stays as a wrapper for callers
that already hold the document. `with_text_layer` and `with_text` hand their
closures the resolution, the only thing any of them read from the copy.

type.edit on a 6000 x 4000 document: 60 filled layers 5.60 -> 5.14 ms,
20 layers 4.33 -> 3.90 ms (median of 300).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
2026-10-17 13:40:31 -04:00
highperfocused f7e441244f fix(ui): Color Picker updates the colour on press, not on release (#6937)
The hue/component strip and the colour field only reacted to
dragged() || clicked(), so a click-and-hold updated the selection only
when the button was released. Photoshop updates immediately on the
press. React to is_pointer_button_down_on() instead, which is true from
the press frame onwards and covers dragging too.

Regression test drives the picker with a press-and-hold on the hue
strip and asserts the colour changes before release.
2026-10-17 19:39:10 +02:00
denstu473 ceb826e270 fix(atn): map Apply Layer Comp steps by name (#7101) 2026-10-17 18:30:57 +02:00
dotCooCoo 263dd041a6 Type: register craft fonts of every script, not only the Japanese ones
build.rs embeds every font in craft-fonts' fonts/manifest.txt, but
FontDb::new registered only the entries whose scripts include Jpan.
craft-fonts 8dcdacd5 adds Noto Sans CJK SC and Noto Sans Arabic, and
3d9ab3a1 adds Noto Sans Thai. Built with either checkout, the binary
carried those fonts, but the font list did not show them, text layers
did not fall back to them, and works_without_craft_fonts failed with
"Noto Sans CJK SC registered under its manifest name". Chinese and
Arabic set in Inter in a TextEngine::new() engine drew .notdef glyphs.

FontDb::new now registers every CRAFT_FONTS entry. A single-face file
registers under its manifest family, whatever family its name table
gives; each face of a collection keeps the family its name table gives.

With Noto Sans CJK SC built in and no Simplified Chinese system font,
Japanese text set in Inter takes Noto Sans CJK SC when the UI locale is
not Japanese, following cjk::script_order.
craft_fonts_cover_japanese_without_system_fonts now expects the first
CJK craft family in that order.

New tests register in-memory craft entries made from Inter (Hans, Arab
and Jpan entries, two whose name tables give another family, and a
collection), and lay out Chinese, Arabic and Thai in Inter when the
build has a craft font for the script.
2026-10-17 09:17:03 -07:00
Jazz1-6 2240a9cb05 docs: fix subject-verb agreement in downloads section 2026-10-17 21:40:34 +05:30
dotCooCoo c537033b4d Smart Filters: double-clicking a Liquify filter reopens it in the Liquify workspace
Double-clicking a Liquify smart filter in the Layers panel opened the
generic parameter form, whose only field is Mesh Size, instead of the
Liquify workspace. OK on that form wrote the new mesh size into the
filter and replayed the recorded strokes on a different mesh. Filter >
Liquify always opened with no strokes, so the recorded strokes could
not be edited again.

smart_ui::open_editor now sends a Liquify smart filter to the new
liquify_ui::open_smart_filter, as it does for Camera Raw and the Filter
Gallery. The workspace opens on the smart object's pixels below that
filter, with the filter's recorded strokes and mesh size. OK runs
layer.smartFilter.setParams with the strokes and the mesh size, so the
filter is updated in one history step and no second Liquify filter is
added. OK after Restore All leaves the filter with no strokes. Cancel
leaves the filter unchanged.
2026-10-17 09:06:55 -07:00
Khawar Zamman WaniandClaude Opus 5.5 f921d9af81 test(engine): keep the range test's group expanded
Group Layers now creates the group collapsed (#5571), so
shift_range_follows_visible_rows_on_both_sides_of_an_expanded_group expands it first, as its name
says. The line is from #5605 by @nrq6993-a11y.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:25:40 +05:30
Elvis33LE 7414914290 test(gpu): tile growth rewrites must beat the kept overlap copy
Grows a raster layer one tile per render and rewrites everything with
per-step noise, on the whole-texture and the paged compositor, comparing
each render against the CPU oracle. Fails at 254/255 when keep_overlap
does not route the resident through encoder-ordered writes (verified by
temporarily dropping new.ordered = true).
2026-10-17 17:53:39 +02:00
Khawar Zamman WaniandClaude Opus 5.5 9e2aa98177 Merge origin/main into fix/pen-path-visible-while-panning-6592
Picks up the build fixes on main (#6906, #7053).

Keep main's crates/engine/src/presets/mod.rs: #6906 restores export_presets there, so
this branch's copy of the same line would set the field twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:18:38 +05:30
Khawar Zamman WaniandClaude Opus 5.5 4bfaa89723 Merge origin/main into fix/5571-group-layers-collapsed
Picks up the build fixes on main (#6906, #7053).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:18:38 +05:30
Khawar Zamman WaniandClaude Opus 5.5 58d4620a8d Merge origin/main into fix/6483-type-commit-on-layer-change
Take main's version of crates/algo/src/features.rs: #7053 gates the same test helpers this
branch gated in 6bda896a.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:08:47 +05:30
Higgs32584andClaude Opus 5.5 eab7603ead background_redraw test: check the opacity change starts a redraw, not the perf label after one frame
The label is only recorded when the redraw finishes, so after a single step it still
read "kept" on slower adapters (FredApps' run on Linux's software adapter). Assert that a
redraw is running instead, and that it finished as "background".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:17:37 -04:00
Higgs32584 f1820242e1 Merge remote-tracking branch 'origin/main' into pr-6908 2026-10-17 11:17:31 -04:00
Higgs32584andClaude Opus 5.5 9f74db6980 Merge origin/main into fix/2603-toolbar-click-without-hover (pick up the #6906 build fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:17:19 -04:00
Higgs32584andClaude Opus 5.5 3d53024a99 GPU canvas: test layer-page admission under a zero budget (#6563)
A paged render with a zero resident budget evicts the previous cell's pages as each
new page is created, stays on the GPU and matches the CPU compositor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:16:45 -04:00
Higgs32584andClaude Opus 5.5 d35c5032ed Merge main (with the #6906 build fix) into fix/6865-selection-not-dirty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:12:10 -04:00
Higgs32584andClaude Opus 5.5 fcea662989 style: sort touch_twins module per rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:07:30 -04:00
dotCooCooandbrandongunderson 45e15036bf Presets: the damaged-set test also covers Export As presets
a_damaged_preset_set_keeps_the_other_sets (#5602) checks that one
damaged set in the preferences file does not discard the others. It
left out export_presets, which #5344 added and #6906 put back into
load_presets_json, and recent_documents.

The test now saves an Export As preset and checks that it survives each
damaged set, recent_documents included. It also damages export_presets
and documents on their own, and gives a null set beside the Export As
presets. This is brandongunderson's change to the test in #6479,
unchanged.

Co-authored-by: brandongunderson <[email protected]>
2026-10-17 07:57:59 -07:00
Khawar Zamman WaniandClaude Opus 5.5 cd82af164f Merge origin/main into field-wheel-5473
Picks up the build fixes on main (#6906, #7053).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:23:14 +05:30
dotCooCoo 4c43ab3ffe Merge main into fix/save-added-extension-asks-before-replacing 2026-10-17 07:47:04 -07:00
nrq6993-a11yandCursor 0c7c533553 Fix rustfmt on baked gradient overlay reopen test.
CI Format step failed on multi-line s.execute in layer_style.rs.

Co-authored-by: Cursor <[email protected]>
2026-10-17 17:44:03 +03:00
abc2cbandClaude Opus 5.5 2430664dd3 Smart Filters: note under Mode when Dissolve is selected
Dissolve in Smart Filter blending follows the inherited color-blending path
without per-pixel coverage sampling (docs/smart-filters.md). Show a short faint
note under the Mode control in Blending Options when Dissolve is chosen, so the
limitation is visible where the mode is picked. No behaviour change.

The note is added to all 20 catalogs. The test renders the dialog body for
every layer blend mode and checks that only Dissolve paints the note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:20:39 +03:00
abc2cbandClaude Opus 5.5 d685aa1484 Fill layers: pin the preference-off precedence for selection and explicit path
With "Use Default Masks on Fill Layers" off, only the default reveal-all mask
goes away. Cover that contract directly for Solid Color, Gradient and Pattern
Fill: no selection and no path gives no mask; an active selection still
becomes the raster layer mask (revealing inside, hiding outside); an explicit
vector path still becomes the vector mask, with or without a selection, and no
raster mask is added next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:19:18 +03:00
Khawar Zamman Wani d182b2de15 Merge remote-tracking branch 'origin/main' into field-wheel-5473
# Conflicts:
#	crates/ui-egui/src/widgets.rs
#	docs/ui-parity.md
2026-10-17 19:44:20 +05:30
Khawar Zamman WaniandClaude Opus 5.5 87e8e5977c fix(engine): restore export_presets in load_presets_json (#5344 x #5616)
main stopped compiling when #5616 rebuilt Persisted field by field without
the export_presets field #5344 added. Same one-line fix as #6906, so it
merges cleanly whichever lands first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:19:16 +05:30
Khawar Zamman Wani fadeb802ef Merge remote-tracking branch 'origin/main' into fix/5571-group-layers-collapsed 2026-10-17 19:13:45 +05:30
Khawar Zamman WaniandClaude Opus 5.5 6bda896ad0 test(algo): gate the Linux-only hash test's helpers to Linux x86_64
`blocks` and `fnv` are only used by the harris/describe hash test, which runs
on Linux x86_64 only, so on Windows they were dead code and `-D warnings`
failed the build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:08:27 +05:30
nrq6993-a11yandCursor 1c760a93b5 Fix Layer Style dialog reloading baked gradient overlay endpoints.
Fixes storytold/photocraft#7031: classic/PSD multi-stop overlays no longer show factory black-to-white when reopened.

Co-authored-by: Cursor <[email protected]>
2026-10-17 16:29:17 +03:00
abc2cb 193e86a66f Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-17 16:25:38 +03:00
abc2cb a6b5208aad Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-17 16:25:38 +03:00
abc2cbandClaude Opus 5.5 e727c3dcf8 Layers panel reorder preview: share move_to's structural move, clipping groups included
The hover preview (preview_move) re-implemented the layer removal and
insertion but skipped the rest of move_to: the clip-group rules
(clip_group::Before/settle), the Background copy rules (unlocked copy)
and the Background-stays-at-the-bottom check. So the preview showed
different clipping than the drop commits, e.g. B C1* C2* U with U dropped
above C1 committed B C1* U* C2* but previewed U unclipped, and moving a
clipping base away left its layers clipped in the preview.

Factor the document-only part of move_to into apply_move and call it from
both, so they cannot drift. A drop move_to would refuse (Background off
the bottom, groups nested too deep) now has no preview either.

Tests compare preview and committed documents (stack order, clip flags and
the flattened composite) for clip-group drops, a base moved away or up
into its own group, option-drag copies, a Background copy, and refused
Background drops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:37 +03:00
abc2cbandClaude Opus 5.5 2197040799 Filter preview: main's view-capped factor follows the whole smart filter stack
The merge with main kept both factor lines in ensure_filter_preview, and the
second (main's filter_preview_factor, #6351) shadowed smart_preview_factor, so
a smart filter edit was sized by the edited command alone again. The shared
filter_preview_factor now starts from smart_preview_factor, which falls back to
preview_factor for ordinary filters, and caps that by the view as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:36 +03:00
JoeandClaude Opus 5.5 dfb8fe071a Relink to File: ask for the file (menu bar and layer context menu)
Layer > Smart Objects > Relink to File... ran the engine command without a
path, so it always failed with "pass `path`". Front it with the open-file
picker like Replace Contents..., relinking every instance to the picked
file's path (the web picker's name-only answer is an error). A single smart
object's layer context menu now also offers Replace Contents... and Relink
to File... after Edit Contents, as in Photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:01:31 +07:00
nrq6993-a11yandCursor 3ee53abe7b Run rustfmt on widgets.rs so Linux CI format check passes.
Co-authored-by: Cursor <[email protected]>
2026-10-17 14:18:48 +03:00
Shubham Bhamare f112e68757 fix(i18n): refine Hindi pluralization, tools, and terminology 2026-10-17 16:24:19 +05:30
Shubham Bhamare 9e2ec2cc83 fix(i18n): refine Hindi Camera Raw terminology 2026-10-17 16:08:40 +05:30
Matthias Schlich 875ec69fce fix(filters): Clouds size their pattern by the canvas, not the selection
Since Clouds became a filter, its cloud size came from the filter's reference bounds, which are
the selection's when there is one: clouds in a selection were finer than the canvas's. Before,
they were sized by the document. Clouds now take the canvas as their bounds; the selection only
masks them, as before (review on #6160).
2026-10-17 12:28:10 +02:00
Muhammad Saad f13731a51a Text: count both coverage buffers on the plain path (#4979)
Coverage::finish allocates its output while the accumulator it reads is
still live, so a plain pass peaks at two coverage buffers rather than
one. The budget charged one, which contradicted finish's own doc comment
and left the plain path under its real peak. The bold path's three
buffers were already right.

The exactness assertion in the budget test now pins 18 bytes a pixel for
a grey 8-bit plain render, and fails if the undercount comes back.

Caught in review by @FredApps.
2026-10-17 14:26:50 +04:00
Shubham Bhamare 5361595c8c docs(contributors): add theshubh77 to people.toml 2026-10-17 15:49:24 +05:30
Shubham Bhamare 1819e77c30 feat(i18n): add Hindi (hi) language catalog and locale resolution 2026-10-17 15:49:19 +05:30
Shubham Bhamare 50d42006b5 fix(engine): populate export_presets in Persisted initialization 2026-10-17 15:49:12 +05:30
Matthias Schlich c6fff8a3a8 Merge origin/main (up to a1e19a0b) into feat/psd-render-smart-filters
# Conflicts:
#	crates/engine/src/smart_cmds.rs
2026-10-17 12:10:28 +02:00
dotCooCoo a78e9f05fc Save: ask before replacing a file that an added or lowercased extension names
The save dialog asks before replacing only the file it answers with.
After it closes, pick_save can change that path: it gives a name
without an extension the suggested file's extension (#5039), and
Lowercase Extension changes CAT.PSD to CAT.psd. The save then wrote to
the changed path at once, and a file there was replaced without a
question. On Linux this happens when the Swatches panel exports to a
folder that holds Swatches.aco (the one-type portal dialog shows and
asks about "Swatches"), when CAT.PSD is typed where CAT.psd exists, and
in rfd's zenity fallback dialog.

When the path written differs from the dialog's answer and a file
exists there, the save now asks "“{name}” already exists. Replace it?".
Replace writes the file, and ui.dialog.confirm returns the save's
result. Cancel writes nothing and ends the save like the dialog's
Cancel, and Save in the close prompt of an untitled document returns
to that prompt. PhotoCraft counts the file dialog as open until the
question is answered.

A name that reaches the same file, as CAT.PSD and CAT.psd do on
Windows and macOS by default, is not asked about again. The new
same_file service decides this: the desktop app compares device and
inode on Unix and the canonical paths on Windows. The web has no file
stamps and does not ask. The two new strings have rows in all 20
catalogs.
2026-10-17 03:08:09 -07:00
nrq6993-a11yandCursor 59660345f9 fix(ui): keep Color panel hex/RGB readout visible in short docks
When the pro Color field stretched to a 120 pt minimum, the hex and RGB
controls were clipped below the scroll area in compact groups (storytold/photocraft#6885).

Co-authored-by: Cursor <[email protected]>
2026-10-17 12:34:02 +03:00
abc2cb 538d9e0ece Merge branch 'main' into codex/github-smart-filters-usability-20261011 2026-10-17 12:13:35 +03:00
nrq6993-a11yandCursor 8b3ea4174e Collapse field_step conditionals for clippy on CI.
Co-authored-by: Cursor <[email protected]>
2026-10-17 12:00:42 +03:00
highperfocused 9328c735af algo: gate the blocks/fnv test helpers to the test that uses them
The recorded-hash test for harris/describe is cfg-gated to
linux x86_64 (the libm the hashes were recorded from), but its two
helper fns were not, so on other platforms the lib test build has dead
code and fails under -D warnings (#6489).
2026-10-17 10:46:02 +02:00
wasif 242d1cfec0 docs(xtask): point the layers help text at docs/architecture.md
cargo xtask --help and the layers command output both referred to plan/architecture.md, which does not exist in this repository; there is no plan/ directory at the root. The dependency layering rules are documented as section 3 of docs/architecture.md, so point both references there.
2026-10-17 12:37:34 +04:00
Khawar Zamman WaniandClaude Sonnet 5.5 44406fa17e Merge origin/main into fix/6483-type-commit-on-layer-change
Keeps both the caret-blink reset from main and the document-aware TextEdit.
Also adds the missing export_presets entry in load_presets_json, which broke
the engine build on main.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:58:28 +05:30
highperfocused e8cd8c917c engine: restore export_presets in load_presets_json (fixes #6911)
The Persisted initializer in Session::load_presets_json lost the
export_presets field when the sets were refactored to load independently
(#5616 landed after #5344 added the field), so photocraft-engine does not
compile on main. One-line build fix, kept as its own commit: drop it once
#6906 or #6930 lands.
2026-10-17 10:24:23 +02:00
highperfocused d36e6dfdbd Crop tool: switching tools drops an untouched ratio-fitted frame instead of cropping (#6750)
With a crop ratio preset that differs from the document, entering the Crop
tool fits the default frame to the ratio. That fit went through keyed(),
which marks the frame as drawn, so pressing V (or picking another tool)
committed the crop and changed the document even though the user never
touched the frame. #3451 (#3368) established that an untouched default
frame is dropped on leaving the tool and only a drawn one commits; the
auto-fit was accidentally turning the untouched frame into a drawn one.

fit_frame now moves the untouched default frame to the fitted rect
without clearing default_frame, so leaving the tool drops it like the
plain default frame. Everything else keeps its old meaning:

- a handle resize, a nudge or a drag inside the fitted frame still makes
  it a drawn frame, and leaving the tool commits it (#3368);
- Enter still commits the fitted frame (it crops to the ratio, as
  choosing_a_ratio_fits_and_commits_without_a_resize pins);
- a W x H x Resolution output size is an explicit instruction and still
  marks the frame pending even when it already fits (crop_size.rs).
2026-10-17 10:17:15 +02:00
Khawar Zamman WaniandClaude Sonnet 5.5 bdf8d06926 Merge origin/main into fix/pen-path-visible-while-panning-6592
Also restores the build on main: load_presets_json lacked the new export_presets field.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:46:45 +05:30
nrq6993-a11yandCursor 3148b32a74 Limit decimal arrow steps to slider_row_prec adjustment rows (#5914).
Display-only decimals on value_field_prec and value_field_units keep range-based 1.0 steps on wide fields like New Document dimensions.

Co-authored-by: Cursor <[email protected]>
2026-10-17 11:05:47 +03:00
Ufuk Efe 95211d33ae Merge branch 'upstream/main' into fix/tr-corrections 2026-10-17 10:23:19 +03:00
dangphuc2470 e249382ae6 fix(engine): preserve Background layer on Merge Layers 2026-10-17 12:19:59 +07:00
MarekSpodBiedry d9dc34711b Don't pan the canvas when a dialog's title bar is dragged (#6810) 2026-10-17 06:56:45 +02:00
Khawar Zamman Wani a2809d0040 Merge commit '13225791' into fix/5571-group-layers-collapsed 2026-10-17 09:37:42 +05:30
Higgs32584andClaude Opus 5.5 e8834e8d93 Selections don't mark a document modified (#6865)
Making, changing or dropping a selection (marquee, lasso, Magic Wand,
Quick / Object Selection, Select menu) is still a history step, but it
no longer dirties a clean document, nor do its undo, redo or a History
panel jump over selection-only states. A step counts as selection-only
when the documents differ in nothing but `selection`
(`layer_multi_cmds::selection_only_change`: pixels by tile pointer,
every other field structurally, exhaustive so new fields must be
classified).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:06:18 -04:00
Omar Saidahmed 97a42733ab test pasting a layer style onto the background 2026-10-16 22:44:01 -04:00
Higgs32584andClaude Opus 5.5 1be48a8360 fix(web): one press per tap, so menus open again on touch (#2095)
In the browser eframe 0.36 turns one finger or pen tap into two primary
presses and two releases: one pair from Pointer Events (pointerdown runs
a frame of its own) and one from Touch Events (touchstart/touchend). The
menu bar titles and press menus toggle on the press, so the twin press
closed the menu the first had just opened.

The web shell's raw_input_hook now drops a primary press that arrives
while the button is already held (pressed within 0.5 s) and a release
while it is already up. A press held longer still passes, so a lost
release can't lock the pointer down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:38:29 -04:00
Michael Higgins cd9ec5c03e Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-16 22:24:35 -04:00
Florent Siegenthaler 513ad27174 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks.
2026-10-17 04:23:46 +02:00
Michael Higgins 476a0fc3ef Merge branch 'storytold:main' into fix/2603-toolbar-click-without-hover 2026-10-16 22:20:24 -04:00
Michael Higgins b9dda08d01 Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-16 22:18:54 -04:00
Michael Higgins 81a4342b18 Merge branch 'storytold:main' into fix/6528-solo-restore-cache 2026-10-16 22:18:47 -04:00
Higgs32584andClaude Opus 5.5 85acfeee65 Background redraw: keep the last finished composites, so a solo's restore uploads instead of recompositing (part of #6528)
Over the GPU memory budget a large visibility change is redrawn by the
CPU compositor in blocks, and nothing kept the result: Alt-click on an
eye and again recomposited every layer (4 s on a 50 MP PSB, ~100 s on a
multi-GB one). The last two finished redraws are now kept, keyed by the
whole document they drew (compared with ==, as the GPU prefix cache
does), the display texture key, the texture format and the block grid;
a redraw of an equal state uploads those blocks. Bounded by what Memory
Usage leaves beside the document and its History, dropped with the
document.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:14:47 -04:00
Higgs32584andClaude Opus 5.5 3f89289b84 test(ui): a toolbar click with no prior hover picks the tool (part of #2603)
The tool buttons are plain Sense::click widgets and need no hover first.
The offset the report describes is #2246, fixed by #2401 in 0.7.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:13:08 -04:00
euisuhandOpenAI Codex f58107b505 docs: record Object Finder foreground detection fix
Document the boundary-based classification change and keep semantic segmentation explicitly open so the parity notes do not imply Photoshop-level recognition.

Closes #6548

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-17 10:13:21 +09:00
euisuhandOpenAI Codex d7fabb5240 wip: fix Object Finder foreground classification (#6548)
Use canvas-boundary coverage instead of a total-area cutoff so dominant enclosed foregrounds reach GrabCut while their surrounding backdrop stays unselected. Verify independent masks, 8/16/32-bit commands and undo, and 24 MP release timing.

Closes #6548

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-17 10:03:19 +09:00
Vikram Bhaskaran 1a98515ab1 Allow layer comps to be reordered without changing the applied state. 2026-10-17 11:37:00 +11:00
Michael Higgins 7072965705 Remove duplicate Cicp import from lib.rs 2026-10-16 20:31:17 -04:00
Michael Higgins d356513052 Merge branch 'storytold:main' into fix/6752-heic-hlg 2026-10-16 20:27:09 -04:00
Higgs32584andClaude Opus 5.5 0769fa22ff Actions: map Select Subject, Select and Mask, Color Range, clipping masks, layer masks and colours from .atn (part of #6745)
autoCutout -> select.subject, smartBrushWorkspace -> select.refineEdge,
colorRange presets -> select.colorRange, groupEvent/ungroup -> create/release
clipping mask, make of a layer mask -> layer.layerMask.*, set of the
foreground/background colour -> tools.setColors. Selective Color's yellows
range is Photoshop's Ylws (legacy Yllw still reads). Steps whose params
can't be translated stay unsupported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:55:09 -04:00
NetterSilverandClaude Haiku 5.5 2466f5c3a7 Disable Convert to Smart Object for layers that are already smart objects
Fixes #6730

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 01:52:06 +02:00
Higgs32584andClaude Opus 5.5 3e373ddef8 GPU canvas: count pages evicted to admit a layer texture in render stats (#6563)
Admission now evicts before creating a page, so the post-cell evict found
nothing over budget and Stats::evicted stayed 0 (pages_are_evicted_under_the_budget).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:43:00 -04:00
Higgs32584andClaude Opus 5.5 41bc07d8f5 HEIF: read the nclx colour description; HLG/PQ HEICs open as linear BT.2020 32-bit (#6752)
The colr nclx primaries and transfer were dropped, so a 10-bit BT.2020 HLG
HEIC opened as if its samples were sRGB. photocraft-heif now returns the
nclx (inherited from grid tiles like the ICC profile), codecs decodes PQ and
HLG to linear light in 32-bit float (BT.2100 inverse OETF + OOTF at 1000
cd/m2, 203 cd/m2 diffuse white = 1.0), and io tags the document with the
matching profile from the new photocraft_cms::builtin::cicp_rgb. BT.709/sRGB
SDR files are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:24:47 -04:00
rudrakshagarwal4200-ai d24d25f5e7 Enhance fitImage tests for dimension constraints
Added tests for handling zero or negative dimensions in fitImage.
2026-10-17 04:01:18 +05:30
rudrakshagarwal4200-ai d0ea5f23cb Change fit_image to use size_param for dimensions
Updated fit_image function to use size_param for width and height.
2026-10-17 03:59:34 +05:30
MarekSpodBiedry 10fc2a3839 Hue/Saturation: hue tracks and spectrum bars use full-strength sRGB hues, one shared hue_color (#6540) 2026-10-17 00:27:42 +02:00
MarekSpodBiedry 4c730d3889 Object Finder: hovering with an empty active layer shows the status-bar hint (#6549) 2026-10-17 00:10:17 +02:00
MarekSpodBiedry 76f19e6e2e Title bar: only the primary button starts a window drag (#6729) 2026-10-17 00:06:32 +02:00
MarekSpodBiedry 6511eed63d Actions: Shift-click layer select in .atn imports as range select and exports back (#6784) 2026-10-17 00:03:31 +02:00
Higgs32584andClaude Opus 5.5 257f0a0d31 GPU canvas: a layer that doesn't fit GPU memory evicts and retries instead of breaking the GPU (#6563)
Compositor::sync created each pc_compose_layer page texture with a bare
create_texture: nothing checked the new bytes against the resident budget
(eviction ran only after each cell) and an Out of Memory error went
uncaptured, invalidating every later texture in the pass.

Now least recently used pages are evicted to admit the new page's bytes
before creating it; creation runs in error scopes (first_error) on native,
and on Out of Memory every page the current cell doesn't use is dropped and
creation is retried once. If it still fails the refresh returns
Unsupported(OVER_BUDGET) so that refresh draws on the CPU and the next one
tries the GPU again; the captured error no longer reaches the health
callbacks. The LRU admission logic is a pure function with a unit test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:34:59 -04:00
Ahmed Tammam 549b4387b1 docs(contributors): add Gotix-eg to people.toml 2026-10-17 00:34:36 +03:00
Higgs32584andClaude Opus 5.5 606b2dadba Actions: a Deselect step with nothing selected plays on instead of stopping (#6744)
Photoshop treats Deselect with no selection as a silent no-op, so imported
.atn actions that start with Deselect stopped at step 1 here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:26:04 -04:00
Finn NewickandClaude Opus 5.5 df023c803a feat(ui): System Info shows the graphics card's own memory
The card's memory caps the GPU budgets (#5234), so a huge canvas can fall
back to the CPU on a card with little of it. System Info (Help and
Preferences › Performance) now lists "Graphics memory" after the driver
when the card reports one; integrated GPUs, which share system memory,
show no such line. Translated in all 20 catalogues.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:52:22 +01:00
Higgs32584andClaude Opus 5.5 7b32097e2a Intel Iris Xe on DX12: no more red blocks in brush previews and tool icons (#6790)
wgpu-hal 30 creates Iris Xe textures as committed resources (its
gfx-rs/wgpu#3552 workaround) with D3D12_HEAP_FLAG_CREATE_NOT_ZEROED, and
egui-wgpu fills each new texture with a single copy. On this driver the
copy leaves 4x4 blocks of all-zero texels showing stale memory, so the
empty corners of Brush Preset picker strokes and tool icons show freed
textures' pixels (often red).

ui-egui's new texel_guard plugin rewrites fully transparent texels in
each frame's texture uploads as premultiplied (0, 0, 0, 1) before eframe
renders them (under one 8-bit level of difference). The desktop app
installs it only for DX12 on "Iris(R) Xe", the same adapter test wgpu
uses; PHOTOCRAFT_TEXEL_GUARD=1/0 forces it on or off.

The gpu example zero_block_probe reproduces the driver bug without the
UI: 630/1500 egui-style uploads read back wrong, 0/1500 with the guard.
Driving the release app (tool cycling, picker rescaled): 6/40
screenshots with red blocks before, 0/120 after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:42 -04:00
Finn Newick 82aa300b51 Merge remote-tracking branch 'origin/main' into fix/bundle-save-lock 2026-10-16 21:34:43 +01:00
Sergey Arseev c1499b2037 Puppet warp: Fix discrepancy between preview and the final state 2026-10-16 23:28:03 +03:00
Ahmed Tammam fb99255513 feat(ui): derive transform anchor from pivot and add missing i18n catalogs
- Add 'Reference point location' translation to cs, de, ko, vi, zh-hans, and zh-hant catalogs

- Derive highlighted 3x3 reference point cell dynamically from t.pivot matching point_on_quad

- Add unit test assertions for matching_anchor
2026-10-16 23:22:33 +03:00
Ahmed Tammam f47f6bf86b Merge remote-tracking branch 'origin/main' into feat/transform-reference-point-widget 2026-10-16 22:54:33 +03:00
Sergey Arseev 4cbe912fc5 Puppet warp: Delete and Backspace remove the selected pin 2026-10-16 22:33:35 +03:00
augrclkandGPT-6 388b7f2a05 Merge upstream main into fix/brush-transfer-mixer-controls
Retain both progress histories and integrate the existing Brush Settings changes with upstream. Preserve the response-curve editor, options-label scrubbing and independent preset regression tests where they overlap.

Co-authored-by: GPT-6 <[email protected]>
2026-10-16 21:01:03 +03:00
Marco Arnulfo b4e0d2491d macOS: Beep When Done plays the system alert sound (NSBeep) instead of a terminal bell (#6537) 2026-10-16 19:24:17 +02:00
vangel76andClaude Opus 5.5 bfa5a75b5d Merge upstream/main into feat/4391-contour-library
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:17:17 +02:00
Vikram Bhaskaran d5535f3517 Correct IFF chunky decoding and preserve bitmap transparency. 2026-10-17 03:08:00 +11:00
tareqziad1998andClaude Opus 5.5 8dcc64bd2f Type: paragraph direction buttons in the Paragraph panel and options bar (TYPE-218-2, #218)
The engine has had TextDirection (Auto / LTR / RTL) and type.setStyle's "direction" key, but
nothing in the UI set it. Now:

- Window > Paragraph and the Properties Paragraph section have a row of three buttons under the
  alignment row: Automatic, Left-to-right and Right-to-left paragraph direction. They set the
  selected layers' direction (or the selected text's paragraphs while typing) in one undo step,
  and show the current one.
- The Type options bar has the same three buttons after its alignment buttons. Like alignment,
  they also set the direction new type starts with (new tool option `type_direction`, "auto" by
  default, reset with the tool's other options).
- The glyph is drawn (a pilcrow over an arrow), so no new icon asset.
- The three tooltips are translated in all 20 catalogs (every_tl_literal_is_translated).

Tests: paragraph_direction_buttons_set_and_show_the_direction and
options_bar_direction_sets_selected_layers_and_new_type.

TYPE-218-2 goes from missing to partial: the visual RTL caret (arrow keys moving on screen
rather than in logical order) is still to do. scorecard/type.toml, docs/scorecard.md and
docs/gaps.md (G8) are updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WpQQNxTPBzSLvgAhX27Vtv
2026-10-16 15:58:53 +00:00
NetterSilverandClaude Haiku 5.5 e5a4af7648 Find small objects in large documents with the Object Finder
Fixes #6576

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:44:00 +00:00
augrclkandGPT-6 5e861d68a0 Merge upstream main into fix/brush-settings-lock-accessibility
Retain both progress histories and integrate the existing Brush Settings changes with upstream. Preserve the response-curve editor, options-label scrubbing and independent preset regression tests where they overlap.

Co-authored-by: GPT-6 <[email protected]>
2026-10-16 18:24:44 +03:00
augrclkandGPT-6 ec32464195 Merge upstream main into fix/brush-pose-tilt-axis-overrides
Retain both progress histories and integrate the existing Brush Settings changes with upstream. Preserve the response-curve editor, options-label scrubbing and independent preset regression tests where they overlap.

Co-authored-by: GPT-6 <[email protected]>
2026-10-16 18:23:34 +03:00
Trade-MottoesandClaude Opus 5.5 8c9c5fccf8 Layer Mask Display Options: set the layer mask rubylith's colour and opacity
The rubylith (`\`, ⇧⌥-click on a mask thumbnail) was always red at 50 %. Its colour and
opacity are now Photoshop's Layer Mask Display Options: one setting for every mask, kept
across restarts and not an undo step, as Photoshop does it. They live in the preferences as
`layerMaskOverlay` (`#rrggbb`, 0..100 %, red at 50 by default) and are set through
`channel.options {"channel":"mask","color","opacity"}`, which with no fields reads them back
(prefs.set and prefs.reset work too).

In the UI, as in Photoshop: double-click the layer mask's row in the Channels panel or pick
Layer Mask Display Options… from its menu, Mask Options… on the Layers panel mask thumbnail,
or Channel Options… with the mask targeted (`\` counts). Each opens the Channel Options dialog
with just Color and Opacity.

The rubylith is still drawn over the picture as it looks, mask applied, as Photoshop draws
it. The channel view's texture key carries the options, so changing them redraws it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:03:46 +00:00
Khawar Zamman WaniandClaude Opus 5.5 ff411fa9e1 test(ui-egui): the Pen-while-panning test expects Windows' fist bitmap
On Windows the fist is a cursor bitmap with a `Crosshair` fallback icon, not the
`None` that `tool_cursor::hand` returns, so comparing the frame's icon to that
return value failed there. Check the icon and bitmap each platform gets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:20:47 +05:30
aqibmohd271 2a9973f9a5 fix(print): preserve WinAnsi characters in PDF labels 2026-10-16 17:49:07 +04:00
abc2cb 80dcc22a70 Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012
# Conflicts:
#	crates/engine/src/jobs.rs
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
2026-10-16 16:42:09 +03:00
abc2cb aba7cfe85e Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012
# Conflicts:
#	crates/engine/src/jobs.rs
2026-10-16 16:42:05 +03:00
abc2cbandClaude Opus 5.5 b0350c64c1 Session::start: drop the checks main moved into admit
The merge with main kept the old precondition, job conflict and hidden layer
checks next to main's `self.admit(...)` (#5684), which runs the same checks,
so every started command was checked twice. The New Effects disclosure block
now follows admit, as it followed those checks before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:41:04 +03:00
Khawar Zamman WaniandClaude Opus 5.5 9b5cb29395 Canvas overlays stay visible while panning (#6592)
Every canvas overlay (the Pen path being drawn, paths, guides, grid,
transform box, crop/slice/gradient overlays) was skipped for as long as
a Hand drag lasted (the tool, Space or the middle button), so the Pen
path vanished while panning. Overlays now always draw; only the tool
cursors yield to the fist (#2196). The transform-only workaround from
#3610 is folded into the general fix.

Test: the_pen_path_being_drawn_stays_visible_while_space_pans (fails
without the fix); transform_preview_stays_visible_while_panning stays
green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:46:16 +05:30
abc2cb a3d72f6f6a Merge branch 'main' into feat/layers-panel-photoshop-options 2026-10-16 14:14:04 +03:00
abc2cb cdfa71557d Merge branch 'main' into codex/layers-subrow-scaling-20261012 2026-10-16 14:13:23 +03:00
abc2cb f0543a19ed Merge branch 'main' into codex/fill-mask-preview-20261012 2026-10-16 14:13:04 +03:00
abc2cb 0ab262a0e3 Merge branch 'main' into codex/github-smart-filters-usability-20261011 2026-10-16 12:20:31 +03:00
Gustavo Ziger 1735344b05 Merge main and refresh animated zoom scorecard 2026-10-16 05:37:50 -03:00
Khawar Zamman WaniandClaude Opus 5.5 444ee06e1a fix(type): keep the layer just selected when ending a Type Mask (#6483 review)
Ending an empty Type Mask undoes its session, and undo restores the layer selection from
before it, so selecting another layer (or Deselect Layers) during the session was reversed.
`end_if_left` now restores the selection the user made when the commit moved it, through a new
`Session::restore_layer_selection` (drops gone layers, refuses a gone active layer, keeps a clean
document clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 13:23:42 +05:30
Khawar Zamman WaniandClaude Opus 5.5 3c91200d88 fix(type): commit a type edit when another layer or document becomes active (#6483)
Clicking another layer while the caret was in a type layer left the edit open, so single-key
tool shortcuts were typed instead. #5612 covered Layers-panel row clicks; every other route
(layer menus, the canvas layer picker, Deselect Layers, automation, a document tab, New/Open)
still left the session open.

A frame guard (`type_tool::end_if_left`) now commits once the edit's layer isn't the active
layer or its document isn't active. `TextEdit` records its document: layer ids repeat across
documents (one .pcraft opened twice), and typing after a tab switch used to edit the other
document's same-id layer. Commits run in the edit's own document; a closed document's edit
is dropped without running anything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 13:23:42 +05:30
ArchersIntelandClaude Sonnet 5.5 8f5d53027c fix(ui): scrolling the Paths or Channels list no longer moves the panel's footer (part of #6238)
The dock lists the tabs that lay out their own scrolling list and footer so it
does not wrap them in a scroll area of its own (Layers, History, Navigator).
Channels and Paths do exactly the same, but were left off, so each sat inside
the dock's scroll area as well as its own, and the two nested areas shared the
mouse wheel. At 125 % display scaling, for some window heights, the outer
content overflowed by one physical pixel and scrolled with the list: the footer's
buttons moved about a pixel while scrolling down and back while scrolling up.

Group::scrolls_itself now covers all three tabs of the Layers group.

Measured: with 30 paths, the footer moved at 31 of 723 window-height and scale
combinations (all at 125 %); with the change, none. The reporter's video agrees:
the footer strip shifts 1 px and back repeatedly while the tab strip never moves.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 02:25:01 -05:00
ArchersIntelandClaude Sonnet 5.5 d27546970f fix(ui): panel footer buttons are no longer cut off at the bottom (part of #6238)
panel_footer lays its 30 px bar over the body's bottom margin ("the bar runs on
over the margin"), but the bar's Ui inherited the body's clip rect, which ends
where the content does. The bottom of every footer button was clipped: a 26 px
hover highlight was drawn about 21 px tall with a flat bottom edge. This is the
"icon looks cut off when you hover over it" in the Paths panel, and it affects
the Layers, Channels and History footers too, since they share the widget.

The footer bar now extends its own clip to the whole bar.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 02:25:01 -05:00
Khawar Zamman WaniandClaude Opus 5.5 8ac3df9c32 test(photocraft): mock portal subscribes before the client calls SaveFile
The mock made its MessageIterator inside the spawned thread. A zbus stream
only sees messages that arrive after it is created, so on a loaded runner the
client's SaveFile could land first, go unanswered, and hang the test until
nextest's 600 s timeout (a_typed_extension_wins_over_an_untouched_file_type
on ubuntu-latest, PR #6468).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:21:41 +05:30
jacknandClaude Opus 5.5 942a581c0c Shapes: the Color panel, Swatches and Gradients fill the selected shape (#5568)
With a shape layer selected, picking a colour in the Color panel, clicking
a swatch or confirming the foreground Color Picker only changed the
foreground colour: the panels recoloured selected type and nothing else,
so the shape kept its fill. A Gradients panel click recoloured a selected
Gradient Fill layer but ignored shapes. As in Photoshop, these now fill
the selected shape layer.

A new foreground fills the shape with a solid colour, keeping the fill's
own opacity; a Color panel drag is one undo step (edits coalesce on the
press time), each click its own. A gradient preset fills the shape with
the gradient, keeping an existing gradient fill's angle, style, scale and
reverse. Locked shapes are left alone: the Color panel only changes the
foreground, and gradient.presets.select refuses before it changes the
Gradient tool's gradient.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:51:39 -06:00
Marco Arnulfo 3c63b20071 Ctrl+Space and Alt+Space zoom the image under an open dialog (fixes #6460) 2026-10-16 07:40:27 +02:00
denstu473 ac8e60ff2e fix(ui): preserve shape stroke settings at zero width
Keep the selected shape's stroke when width reaches zero so subsequent
width edits retain its paint and geometry settings in both UI controls.
Preserve explicit No Color removal and gesture-scoped undo behavior.

Add real-widget regressions for style retention, native persistence,
rendering, undo/redo, and deliberate stroke activation.

Fixes #6191.
2026-10-16 07:11:27 +02:00
Khawar Zamman WaniandClaude Opus 5.5 eee8d8c4ea fix(photocraft): --control 0 logs the port actually bound (#6427)
With port 0 the OS picks a free port, but the control server printed the
requested one (`127.0.0.1:0`), so a client had no way to find it. It now
prints `listener.local_addr()`, like `photocraft-cli serve` does, falling
back to the requested port if the OS can't report it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:34:52 +05:30
Omar Saidahmed 8127c6aa1a keep comps, slices and variables when converting a layer to a smart object 2026-10-16 00:29:20 -04:00
Florent Siegenthaler de718e62fb Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks.
2026-10-16 06:28:45 +02:00
Nicolas FalesyandClaude Opus 5.5 6cef19e38a perf(quantize): Indexed Color palette build 8× faster, bit for bit the same
build_palette (Adaptive, Perceptual, Selective; Exact shares the histogram) now
gives exactly the same colours, faster. Release, 24 MP, 256 colours, median of 5:

| case                    | before ms | after ms | speed-up | 1 thread before | 1 thread after |
|-------------------------|----------:|---------:|---------:|----------------:|---------------:|
| photo-like Perceptual   |      5068 |      601 |     8.4x |            4841 |           1651 |
| photo-like Adaptive     |      4044 |      608 |     6.7x |            3859 |           1251 |
| photo-like Selective    |      4862 |      617 |     7.9x |            4720 |           1716 |
| noisy Perceptual        |     14124 |     1643 |     8.6x |           13726 |           4018 |
| noisy Adaptive          |     10891 |     1485 |     7.3x |           10600 |           2701 |

Max RSS, photo-like Perceptual (whole probe, 384 MB image included): 802 MB
before, 644 MB after. Noisy: 1667 MB before, 1151 MB after.

What changed:
- median_cut keeps each box's (axis, score) and rescans only the two boxes a
  split makes, instead of every box on every split.
- median_cut sorts big boxes with a stable radix sort on the f32 total_cmp
  order (same order as sort_by, ties keep their input order).
- histogram packs pixels to u32 and sorts and counts them, instead of a
  SipHash HashMap insert per pixel.
- k-means finds the nearest centre for all points in parallel, then adds the
  f64 sums serially in the original point order.
- The Lab conversion of the histogram runs in parallel (order kept).
- to8 no longer calls libm roundf per channel; it is checked equal to
  f32::round for every one of the 2^32 floats.

Every palette hash matched before and after for Adaptive/Perceptual/Selective/
Exact at 256/64/16 colours with no forced colours and Black and White, on a 24 MP
photo-like image, a 24 MP noisy image, a small image with fewer colours than
requested, a 300-colour image and a semi-transparent image. New tests pin a
recorded palette from the old code and compare median_cut, k-means and the
radix sort with the old code bit for bit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
2026-10-16 00:28:03 -04:00
Omar Saidahmed cac01f79b8 match smart object re-renders to the document's mode and depth 2026-10-16 00:27:10 -04:00
Marco Arnulfo ef46343f6b Type options: drag the "T" icon to change the font size, as in Photoshop (#6437) 2026-10-16 06:19:06 +02:00
Khawar Zamman WaniandClaude Opus 5.5 21b8d832af Hand tool: hold Ctrl/Cmd to zoom in, Alt to zoom out (#6434)
With the Hand tool selected, Cmd (Ctrl off the Mac) held is a temporary
Zoom In and Alt (or Cmd+Alt) a temporary Zoom Out, as in Photoshop: a
click steps the zoom, a drag scrubs (or draws a zoom box with Scrubby
Zoom off), the magnifier cursor shows, and letting go gives the Hand
back. Latched Window > Modifier Keys count too.

Not while Free Transform is open (Cmd is its distort key), and a
modifier pressed during a Hand press, release included, doesn't turn
it into a zoom: only a press made with the modifier held zooms.

docs/ui-parity.md: Navigation row added; Scroll All Windows and Bird's
Eye View recorded as missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 09:39:31 +05:30
nrq6993-a11y 801e229d76 style(text): rustfmt antialias fringe_mass chain for #5902 (PR #5907) 2026-10-16 06:47:56 +03:00
jonathnwxh 1d21c921f8 style: apply rustfmt to upstream smart-filter regression 2026-10-16 11:29:21 +08:00
jonathnwxh 2c635c5c3e Merge remote-tracking branch 'origin/main' into repair/6380-upstream-schema 2026-10-16 11:29:20 +08:00
LochLogicandClaude Opus 5.5 708d393988 Tests: every GPU test holds gpu_lock (STATUS_ACCESS_VIOLATION on Windows)
testkit::gpu_lock documents that WARP faults in d3d10warp.dll when two GPU
harnesses render in parallel in one test process, and that every test that
builds a GPU harness or device holds the lock. Fifteen tests didn't, so on
Windows checker_canvas and marquee_autoscroll (and the lib test binary's GPU
tests) could take their whole test binary down with STATUS_ACCESS_VIOLATION.
They now take the lock on their first line, as the others do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:24:07 -06:00
Higgs32584andClaude Opus 5.5 a80ece00aa Merge origin/main; cargo fmt (new_doc_ui.rs, smart_filter_authorization.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:01:51 -04:00
Higgs32584 5d9093bf0c Merge remote-tracking branch 'origin/main' into HEAD 2026-10-15 22:49:34 -04:00
Omar Saidahmed 2b5e857d92 convert the background and match mask depth where they were missed 2026-10-15 22:46:05 -04:00
Omar Saidahmed 6822125f79 keep the rendering of smart filters that cannot be reproduced 2026-10-15 22:34:00 -04:00
Higgs32584andClaude Opus 5.5 b6be26a490 rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:28:54 -04:00
Higgs32584 8cedb81a63 Merge remote-tracking branch 'origin/main' into fix/5712-artboard-pasteboard-hides-loose-layers 2026-10-15 22:28:09 -04:00
Higgs32584andClaude Opus 5.5 73459a662c Artboards: layers outside the boards stay visible over the pasteboard (#5712)
Adding an artboard next to earlier work made that work vanish: the canvas
painted the opaque pasteboard over the whole image outside the boards,
hiding layers that are not in an artboard. Photoshop shows them, so the
pasteboard now leaves out where visible loose layers draw (their
composite bounds).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:44:21 -04:00
Gustavo Ziger 5c941c5656 Merge branch 'main' into feat/zoom-animation-566 2026-10-15 22:17:24 -03:00
cortezsilvano-botandClaude Opus 5.5 6f2abe20fc Print: Functions › Bleed (corner crop marks on the trim line)
Photoshop's Print dialog has Functions › Bleed: crop marks print inside
the image by the bleed width, so trimming on them cuts through the
artwork and leaves no paper-white sliver. target-app-parity listed it as
missing ("No Separations/bleed").

- file.print / file.printOneCopy take "bleed" in inches (default 0),
  report it back, and Print One Copy remembers it.
- Corner crop marks move onto the trim line, still starting 6 pt outside
  the image. Centre and registration marks are unchanged (a symmetric
  bleed doesn't move the midlines).
- One helper (corner_crop_segments) drives both the PDF writer and the
  native Windows primitives; the dialog preview draws the marks there too.
- Negative, non-numeric or too-wide bleeds are BadParams, never a panic;
  bleed without corner crop marks changes nothing and reports 0.
- With bleed absent or 0 the PDF is byte-identical (tested).

The Print dialog field is a follow-up: every tl! label needs all
translations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:14:47 -05:00
Higgs32584andClaude Opus 5.5 eee57c02ce Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:38:26 -04:00
jonathnwxh 6b9fc995ce test: add explicit release cursor generation cost probe 2026-10-16 06:47:44 +08:00
jonathnwxh 79e827ce7a test: optionally capture actual pen cursor bitmaps 2026-10-16 06:47:09 +08:00
jonathnwxh 7f81e3abae Merge remote-tracking branch 'origin/main' into investigate/6350-brush-cursor 2026-10-16 06:46:00 +08:00
jonathnwxh eb3191e9bf fix: apply pen orientation to cached brush cursor outlines 2026-10-16 06:46:00 +08:00
Gustavo Ziger cd1eb38bc5 Merge branch 'main' into feat/zoom-animation-566 2026-10-15 19:45:47 -03:00
Higgs32584andClaude Opus 5.5 c1c44507d1 Automation: file.new's profile path is an ambient read, gated like Assign Profile's (#321)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:40:18 -04:00
Florent Siegenthaler cd53632b62 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks.
2026-10-16 00:36:48 +02:00
jonathnwxh addac100bf test: compare pen cursor with dab angle and static reference 2026-10-16 06:33:09 +08:00
jonathnwxh 6755ab7f22 test: reproduce static brush cursor under pen rotation 2026-10-16 06:32:07 +08:00
Florent Siegenthaler 39922dc317 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new row
is kept as it is and the branch adds only the keys main lacks.
2026-10-16 00:26:59 +02:00
Higgs32584andClaude Opus 5.5 5f9c7d307e New Document: Color Profile, the working space or another profile of the mode (#321)
Photoshop's New Document has Advanced Options › Color Profile; PhotoCraft's always opened in the
working space, so a document in another space had to be made and then assigned a profile.
`file.new` takes `profile` (working, a built-in id or an .icc path), checked against the colour
mode before the document opens, and the New Document dialog lists the working space (by its
profile) and the built-in and installed profiles of the mode's colour space. Changing the mode
drops a profile of the old space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:25:28 -04:00
vngocvan 89d158c98a fix(pen): preserve stale drafts in their owning Work Path 2026-10-16 05:23:26 +07:00
a528b9993e fix(pen): guard every entry point against a stale path
Review on #5412: `sync_views` and `pen_commit` left three ways for a
control batch to reach another document's path in the same frame, before
the next sync. `cancel_stale` now also runs at the top of `pen_down`,
`curvature_down` and `pen_undo_last_point`, and `draw_overlay` draws the
path only on the document it was started on, as #5427 does.

The regression test covers all three entry points after a document switch
and the overlay, comparing the same view with and without the path so the
count measures the path alone.

Co-authored-by: Daktyl198 <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
2026-10-16 05:09:49 +07:00
vngocvan 590787b848 fix(pen): drop the Direct Selection with the stale path
`pen_commit` resets `ui.direct_selection` when it was targeting the Pen's
path; `cancel_stale` dropped the path without it, leaving the target
pointing at anchors that no longer exist. Spotted by @Daktyl198 on #5412.

The test now drives the Curvature Pen as well as the Pen through File >
New, and covers the Direct Selection reset.
2026-10-16 05:09:49 +07:00
vngocvan 4e131ecf2d fix(pen): an unclosed path stays with its document (#5373)
Leaving a path open with the Pen and creating a new document showed that
path on the new document: `UiState::pen` had no document owner, so it
survived the switch.

Follow-up to #5015, which did the same for the Polygonal Lasso. The path
now remembers the document it was started on (`UiState::pen_doc`, set at
its first anchor by `pen_down` and `curvature_down`) and is dropped when
another document becomes active, as the Polygonal Lasso and the Crop tool
already do.

`cancel_stale` also runs at the top of `pen_commit`: the control channel is
drained after `sync_views` (`logic` calls them in that order), so a
document switch in the same frame would otherwise commit the path into the
wrong document. The Crop tool guards its commit path the same way.

Adds a regression test that File > New drops the path, that a switch before
the commit writes it to neither document, and that it still commits to the
document it was drawn on.
2026-10-16 05:09:49 +07:00
Theo Wilenius 26cc0369ee Guides: a ruler drop removes the guide even when the drag held its own axis
Review found that a horizontal guide dragged sideways onto the left ruler keeps
its y, and that reads as a click on the guide: the click branch selected it and
returned before the ruler drop was considered, so the guide survived the drop.

Require `!over_ruler` in that branch, so a drop on a ruler always removes the
guide, and cover the constant-axis case in the direct test and in the canvas
test, which now runs the gesture at two release offsets. `dropped_on_a_ruler` is
now the strips `content_rect` trims off the canvas rect, strictly outside it,
with the boundary, corner and outside-rect cases asserted.
2026-10-16 00:23:51 +03:00
Florent Siegenthaler fdb9f014c4 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks.
2026-10-15 23:21:36 +02:00
Theo Wilenius 3d9d12ec38 Contributors: credit theoabw by real name in the About window 2026-10-16 00:03:56 +03:00
Theo Wilenius 9f8d4bc0ac Guides: a guide let go over either ruler is removed, not only off the canvas (#6286)
A guide was kept whenever the release still read a position inside the document
on the guide's own axis. The two rulers measure one axis each, so a horizontal
guide let go on the left ruler, or a vertical one let go on the top ruler, was
moved, and a new one dragged out of a ruler was created, instead of discarded.

`dropped_on_a_ruler` reports whether the release point is in a ruler band while
the rulers are shown. `GuideDrag` carries the answer and `finish_drag` treats
such a drop as off-canvas. The canvas sets it from the release position in
screen space, where the rulers are; the ruler's own drag sets it from its
pointer position.

Two of the four new tests fail on main: a guide dragged out of the top ruler and
let go on the left ruler is created, and an existing guide dragged onto the left
ruler is moved. A guide released on the canvas still lands where it was let go.
2026-10-16 00:03:53 +03:00
nrq6993-a11yandCursor 07bf389914 Fix Exposure value-field steps when decimals are set.
Fractional stops use the declared decimal precision for arrow keys and scrub steps, not only narrow ranges (storytold/photocraft#5914).

Co-authored-by: Cursor <[email protected]>
2026-10-15 23:45:12 +03:00
nrq6993-a11yandCursor 81b6cdf0d2 text: apply anti-alias mode shaping when rasterizing type
Photoshop's Sharp/Crisp/Strong/Smooth settings only changed metadata; shape edge coverage per mode (fixes storytold/photocraft#5902).

Co-authored-by: Cursor <[email protected]>
2026-10-15 23:45:10 +03:00
vangel76andClaude Opus 5.5 ad12ee294e Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:35:37 +02:00
vangel76andClaude Opus 5.5 e1821fd4fa Merge upstream/main into feat/4391-contour-library
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:35:33 +02:00
Marco Arnulfo 14b1783e93 Smart Filters: Delete/Backspace removes the selected filter, not its smart object (#4751) 2026-10-15 22:14:28 +02:00
Stepan Pavlenko 759b68c234 i18n(ru): translate the dynamic labels the Russian catalog was missing
The Russian catalog passed `cargo xtask i18n-coverage` (100% of measured keys)
but lacked the ~1,233 runtime-only labels the other catalogs already carry, so
they showed in English: filter and adjustment parameter names and choices,
gallery names, Camera Raw panels (Balance/Clarity/Dehaze/Color Mixer/Texture/…),
History/Layers/paths/channels menu labels, metadata and File Info fields,
System Info and hardware status lines, rasterize prompts, composed status
templates and dialog text.

All 1,233 rows are translated (ru rows 2,936 → 4,208), written from the meaning
of the English labels and reusing the vocabulary the catalog already uses
(Highlights → Света, Midtones → Полутона, Feather → Растушёвка, Pattern → узор).
Filter and gallery names are aligned with the menu labels already in the
catalog so a filter never reads two ways (Texturizer → Текстуризатор,
Unsharp Mask → Нерезкое маскирование, Shape Blur → Размытие по фигуре, …).

Tests: `russian_covers_generated_filter_options_and_gallery_names`
(filter_dialog.rs, the Italian/Korean/Spanish check) and
`russian_dynamic_status_hardware_and_dialog_labels_are_translated` (i18n/mod.rs).
Docs: docs/localization-ru.md (terminology), localization-parity, gaps G13,
ROADMAP, ATTRIBUTION and LICENSE-translations.

Validated: `cargo test -p photocraft-ui-egui --lib` 2,935 passed / 0 failed;
clippy clean (the only lint is a pre-existing `nonminimal_bool` in canvas.rs,
untouched here and present on main); `xtask layers` OK; `xtask wasm` OK;
`xtask i18n-coverage` ru 1,886 / 1,886; offscreen `snapshot` in Russian checked.
2026-10-15 22:35:50 +03:00
VladimirPozdnyakov 064d8429d5 Type: ⌥ on a paragraph-box handle resizes the block symmetrically
Dragging an edge or corner of a paragraph text box with ⌥ held mirrors the
opposite edges about the box's centre, so the block grows symmetrically;
without ⌥ only the dragged edges move. The drag snapshots the box and its
transform at the press (state::BoxResize), so ⌥ pressed or released mid-drag
applies as if it had been held from the start — the mirror always turns about
the centre the box started with. The ⌥ box clamps at MIN_BOX about that centre.

Free Transform and the Ctrl-frame already did ⌥-from-the-reference-point; this
closes the plain Type-tool box-edit path.
2026-10-15 21:56:25 +03:00
Marco Arnulfo b11223b272 Codecs: save Cineon (.cin) film frames
Write Kodak Cineon 4.5: the 1024-byte generic header (V4.5, one or three
channels designated gray or R/G/B, 10 bits, code values 0-1023 over
densities 0-2.048) and a 1024-byte film header left undefined, then the
samples three to a big-endian 32-bit word, left justified (packing 5),
lines on a word boundary. Samples are written as round(v * 1023 / 65535),
so a frame opened from Cineon saves back with its code values; other
images take the encode plan (alpha dropped, CMYK and Lab to RGB) and are
quantized to 10 bits.

Cineon leaves ASYMMETRIC_EXCEPTIONS, Save As offers .cin, and the codecs
README, file-format parity and G10 are updated.
2026-10-15 20:41:58 +02:00
Marco Arnulfo 802a1b1790 fix(compose): interior effects see the backdrop through a Normal layer's fill (fixes #6232) 2026-10-15 19:59:05 +02:00
Marco Arnulfo a1502432e5 Paths panel: rename a path in place, and name the Work Path when saving it (part of #6238) 2026-10-15 19:47:59 +02:00
highperfocused 212a8d080b Merge remote-tracking branch 'upstream/main' into fix/file-info-labels-and-order 2026-10-15 19:38:02 +02:00
Elvis33LE f57bb1f3b1 perf(gpu): batch resident overlap copies 2026-10-15 19:30:51 +02:00
IanHollow 3834845a60 Separate shortcut capture checks from native snapshot rendering
Keep capture and visible-label assertions GPU independent. Add opt-in checked snapshots at both scales under the existing GPU lock and Nextest group, with a fresh-output command that rejects empty test selections.

Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-15 09:45:41 -07:00
Marco Arnulfo 59d42f3998 Free Transform: a rotated sprite's soft edge keeps its colour instead of a light border (fixes #6203) 2026-10-15 18:34:00 +02:00
EuanTop d7828bda6f Add hex input to shape Fill and Stroke popovers 2026-10-16 00:01:16 +08:00
Michael Higgins 8c027ba257 Merge branch 'storytold:main' into fix/5863-cjk-menu-mnemonics 2026-10-15 11:49:58 -04:00
Matthias Schlich b228c1c0c2 feat(psd): map Clouds, Difference Clouds, Fibers and Lens Flare smart filters
PSD smart filters Clds, DfrC, Fbrs and LnsF open as editable PhotoCraft filters instead of
kept-verbatim entries, and are written back like Photoshop: seeds, the item's foreground and
background colours, brightness, centre and lens (Zm/Nkn/Nkn1/PnVs = 50-300mm Zoom, 35mm,
105mm and Movie Prime), with Photoshop's descriptor names. Checked against items saved by
Photoshop 27.
2026-10-15 16:05:34 +02:00
Matthias Schlich 50a011ae9e feat(filters): Clouds and Difference Clouds as smart filters; render filters work within a smart object
Clouds and Difference Clouds ran outside the filter pipeline (map_pixels with the session's
colours), so on a smart object they failed instead of adding a smart filter. They are now a
regular render filter (FilterParams::Clouds, same noise as before) that records its colours,
so a smart filter re-renders the same clouds after the colours change.

Render filters on a smart object (Clouds, Difference Clouds, Fibers, Lens Flare) now use the
placed content's bounds, as Photoshop does: a flare's centre is a fraction of the object, not
of the canvas.
2026-10-15 16:05:34 +02:00
Claude 6bba63da5c Merge storytold/main into Pen options bar: Make Selection (#5703)
Resolves the append-only conflicts in the ko, zh-Hans and zh-Hant catalogs
(keeps main's Keyboard Shortcuts set rows and this branch's Make Selection
row) and picks up main's CI fix (#5941) so the PR no longer inherits a red main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011cC1iWzaGcNtdsW8LXuKQh
2026-10-15 13:49:52 +00:00
Giang Nguyen ab9e32c01d contributors: credit giang17 as Giang Nguyen 2026-10-15 15:49:47 +02:00
Giang Nguyen 6845cb95f2 Title bar: a window move the window manager finished no longer blocks the next move or resize (#5725) 2026-10-15 15:49:47 +02:00
Michael Higgins 869c2a378d Merge branch 'storytold:main' into fix/5745-auto-blend 2026-10-15 09:40:34 -04:00
highperfocused 1d2ebfdc1f File Info: Photoshop field order and labels (#6125, #6126) 2026-10-15 15:35:49 +02:00
Michael Higgins f571673fc2 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-15 09:34:15 -04:00
Michael Higgins 8251a722b9 Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-15 09:34:00 -04:00
Vikram Bhaskaran 0c439b66e2 Format and compile the merged DICOM reader. 2026-10-16 00:25:36 +11:00
Vikram Bhaskaran 529fb667f9 Merge remote-tracking branch 'origin/main' into feat/dicom-read
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	crates/codecs/README.md
#	crates/codecs/src/format.rs
#	crates/codecs/src/lib.rs
#	docs/file-format-parity.md
2026-10-16 00:22:26 +11:00
Vikram Bhaskaran 1e9c0b43f6 Format the merged open-extension lists. 2026-10-16 00:21:14 +11:00
Vikram Bhaskaran 4623c3068c Fix the detect chain after the main merge.
The shared closing brace left the Cineon test doubled when the two
magic checks were stitched together.
2026-10-16 00:20:51 +11:00
bazmarth 7978cbc1ee Merge remote-tracking branch 'origin/main' into feat/polygon-corner-radius
# Conflicts:
#	docs/ui-parity.md
2026-10-15 13:17:31 +00:00
Vikram Bhaskaran 2af160d65f Merge remote-tracking branch 'origin/main' into feat/iff-read
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	crates/codecs/README.md
#	crates/codecs/src/format.rs
#	crates/codecs/src/lib.rs
#	crates/codecs/tests/detect_caps.rs
#	docs/gaps.md
2026-10-16 00:17:24 +11:00
Omar Saidahmed 65ca7c1bbd remove unused deps and dead code 2026-10-15 09:00:20 -04:00
mroxso 33372650fe Merge branch 'storytold:main' into main 2026-10-15 14:55:11 +02:00
Vikram Bhaskaran 6b7f08fcd6 Merge remote-tracking branch 'origin/main' into fix/5258-psd-open
# Conflicts:
#	crates/io/src/lib.rs
2026-10-15 22:56:14 +11:00
Vikram Bhaskaran 26cdfae307 IO: route declared Photoshop extensions through is_psd_input.
The helpers-only use made cargo clippy -p photocraft-io --all-targets
flag is_psd_input as never used; import_stages now calls it for the
extension branch, keeping the magic-first order that lets an Affinity
file renamed .psd stay with the Affinity loader.
2026-10-15 22:55:05 +11:00
Gustavo Ziger 0684657a92 Merge main into feat/zoom-animation-566 2026-10-15 08:35:41 -03:00
jonathnwxh de095be6cb perf(compose): track the last covered sample to reset empty sums 2026-10-15 19:26:04 +08:00
jonathnwxh 0e431aaf6d fix(compose): keep empty blur tails empty before effect noise 2026-10-15 19:21:26 +08:00
jonathnwxh c4f383e190 test(compose): reproduce noise in empty effect blur tails 2026-10-15 19:18:39 +08:00
Hasan 3b97dc4f2c Free Transform: picking another tool or layer applies a background commit (#5665, #5667)
In the desktop app Free Transform commits as a background job. When
another tool or layer was picked, end_if_left committed the box (#1400)
and then, seeing it still open while the job ran, cancelled it as if
the engine had refused the commit. Cancelling the job threw the
transform away: an existing layer went back to where it was, and a
dropped file was taken back, or, after New Layer, kept untransformed
because its place was no longer the latest step (#5667). The tests
commit inline, so they never saw it.

A commit still running now ends the session when it lands. If the
engine refuses it then, the box ends as Esc would, as an inline
refusal already did.

A transformed place folds into its Place Embedded step only when the
place is the step just before the transform. After New Layer, the
place, the new layer and the transform stay three steps, instead of the
new layer being folded into the transform and labelled Place Embedded.
2026-10-15 11:02:37 +00:00
nrq6993-a11y 8be3269fe1 style: rustfmt 2026-10-15 13:51:51 +03:00
nrq6993-a11yandCursor 579e0f1d28 Workspace: list saved workspaces in Window › Workspace and the switcher (storytold/photocraft#5610)
Saved workspaces get dynamic Window › Workspace items after the presets
(window.workspace.saved.<index>), checked while current and selecting
the workspace; the top-bar dropdown lists them after the presets.

Co-authored-by: Cursor <[email protected]>
2026-10-15 13:40:49 +03:00
nrq6993-a11yandCursor b18f6ea008 History panel: scroll to the current state on document change.
Fixes storytold/photocraft#5909 when new edits land off-screen in a short dock.

Co-authored-by: Cursor <[email protected]>
2026-10-15 13:40:39 +03:00
Jason Xiang d3f6b384ac Merge upstream main into fix/zip-comment-directory
Include upstream formatting, ui.set schema, and web packaging CI fixes.
Preserve the focused feature changes and regression tests.
2026-10-15 02:15:16 -07:00
Jason Xiang 4b12cc6d65 Merge upstream main into fix/dds-row-pitch
Include upstream formatting, ui.set schema, and web packaging CI fixes.
Preserve the focused feature changes and regression tests.
2026-10-15 02:14:24 -07:00
jonathnwxh 31916eb9b0 Merge main preserving managed transform content below guides 2026-10-15 17:04:08 +08:00
abc2cbandClaude Opus 5.5 eae5eecab5 Smart filter preview: don't scale the placement and mask twice
Main's proxy_document now scales a smart object's placement, perspective and
filter mask with the proxy (#5779). The smart-edit preview still did it again,
so a reduced preview drew the object and its mask at a quarter size
(reduced_preview_scales_placement_stack_and_mask_without_adding_filters).
Only the filter lengths and the mask's feather are left to scale here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:59:13 +03:00
abc2cb 0a353ae85e Merge branch 'feat/layers-panel-photoshop-options' into codex/fill-mask-preview-20261012 2026-10-15 11:20:55 +03:00
abc2cb e47b4ddc1c Merge branch 'feat/layers-panel-photoshop-options' into codex/layers-subrow-scaling-20261012 2026-10-15 11:20:54 +03:00
abc2cb c68541dc26 Merge remote-tracking branch 'upstream/main' into feat/layers-panel-photoshop-options
# Conflicts:
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
2026-10-15 11:20:36 +03:00
abc2cb 7635c61f89 Merge remote-tracking branch 'upstream/main' into codex/github-smart-filters-usability-20261011
# Conflicts:
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/smart_ui.rs
2026-10-15 11:20:35 +03:00
vngocvan 3d95193ef6 fix(ui): keep Clone Stamp source preview crisp when zoomed in (#5995) 2026-10-15 14:51:12 +07:00
Muhammad Saad c77899b2a7 Text: admit the rasterizer's peak working bytes (#4979)
rasterize_warped guarded its size with MAX_PIXELS alone, which says nothing
about bytes. The buffers it keeps live at once are all as deep as the format:
the f32 accumulator and the output surface are both stride deep, a finished
coverage buffer sits beside them for the colour being drawn, and each faux-bold
pass holds a second Coverage and the vector its finish builds. A CMYK bold
render at f32 therefore reserves several times what a plain grey one does for
the same pixel count, and every one of those buffers went through vec! or
Vec::with_capacity, which abort instead of returning.

Admit the peak before reserving any of it: fits_raster_budget derives it from
the stride, the sample size and whether a bold pass runs, refuses an overflowing
size rather than wrapping, and is checked against MAX_RASTER_BYTES (4 GiB, 1 GiB
where usize is 32 bits, so the wasm build keeps room for the document). Coverage
now reserves fallibly and finish returns an Option, and the accumulator goes
through a fallible zeroed(). A refusal returns the empty Rendered the oversized
path already returned, so callers see the existing "too big to draw" result
rather than a crash, and the document and its cached appearance are untouched.

Ordinary text is unaffected: the budget only binds above what MAX_PIXELS
already allowed for narrow formats.

Test: the peak tracks channels, sample width and faux bold against small
injected allowances, an overflowing size is refused, and a buffer that cannot
be served reserves nothing.
2026-10-15 09:57:02 +04:00
varanus-salvatorandClaude Opus 5.5 3227bf8366 Stroke: keep Overprint (overprint)
Photoshop stores Stroke › Overprint as `overprint` in the FrFX
descriptor. StrokeFx had no field for it, so an edited stroke was
written back without it and a stroke with Overprint on lost it.

Add `StrokeFx::overprint` (serde default, so older .pcraft files load),
read and write `overprint`, accept it in layer.layerStyle.stroke and show
the Overprint checkbox in the Layer Style dialog, as Photoshop does.
Rendering is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:55:18 +07:00
varanus-salvatorandClaude Opus 5.5 92aa5047cd Bevel & Emboss: keep the Gloss Contour's Anti-aliased (antialiasGloss)
Photoshop stores Bevel & Emboss › Gloss Contour › Anti-aliased as
`antialiasGloss` in the effect descriptor. The document model had no
field for it, so import dropped it and export regenerated the bevel
without it: once a bevel was edited (the Layer Style dialog re-applies
all its settings), a saved PSD reopened in Photoshop with the gloss
contour aliased.

Add `Bevel::gloss_anti_alias` (serde default, so older .pcraft files
load), read and write `antialiasGloss`, accept `glossAntiAlias` in
layer.layerStyle.bevelEmboss and show the Anti-aliased checkbox next to
the Gloss Contour in the dialog, as Photoshop does. Rendering is
unchanged: contour anti-aliasing is not rendered for any effect yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:31:59 +07:00
Finn Newick 4a70416fc9 Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-15 06:10:54 +01:00
Finn Newick 3d7a021d49 Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	crates/codecs/src/codecs/tiff.rs
2026-10-15 05:55:50 +01:00
Finn Newick 3b41e68a00 Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
# Conflicts:
#	docs/gaps.md
2026-10-15 05:34:01 +01:00
Florent Siegenthaler 90786c8e09 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks (the
duplicate "Blur Angle" and "Random" rows are dropped).
2026-10-15 05:54:25 +02:00
abc2cbandClaude Opus 5.5 2e55f5eaa1 Merge Layers Panel part 1 (es.tsv duplicates dropped) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:36:04 +03:00
abc2cbandClaude Opus 5.5 79a8a795e3 Merge Layers Panel part 1 (es.tsv duplicates dropped) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:36:03 +03:00
abc2cbandClaude Opus 5.5 e3f95672df i18n(es): drop this PR's copies of rows main's catalog now has
main's Spanish catalog (#4306) added New Layer…, Panel Options… and Lock
Layers, so bundled_catalogs_are_consistent failed on the duplicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:35:59 +03:00
Higgs32584andClaude Opus 5.5 86d1630de9 Tests: the Move readout drag starts on the layer (#5727)
With Auto-Select (on by default) a press on an empty spot starts the layer
marquee, not a move; the test pressed at (32, 32), just outside the 16..32
square. It now presses inside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:35:33 -04:00
Higgs32584andClaude Opus 5.5 1f745a9d45 Tests: Displace's map picker is asked when the frame polls it (#5806)
ask_file only queues the request; the file dialog service is called from
poll_file_dialog, so the request count is checked after the poll that shows
the picker and acts on its answer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:35:31 -04:00
Higgs32584andClaude Opus 5.5 d41171f5a1 Tests: the language checks find the File menu with its access key (#5863)
The menu bar draws a title with no Latin letter with its English access key
("文件(F)"), so the three live-language tests now look for the title as the
bar draws it (menu_keys::localized) rather than the bare translation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:35:28 -04:00
abc2cbandClaude Opus 5.5 7d6712603d Merge Layers Panel part 1 (main fd28da56, es.tsv duplicates dropped) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:53 +03:00
abc2cbandClaude Opus 5.5 a5f8463e9b Merge Layers Panel part 1 (main fd28da56, es.tsv duplicates dropped) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:52 +03:00
abc2cbandClaude Opus 5.5 b90ddb338a Merge upstream/main (fd28da56) into Smart Filters editors
server.rs takes main's ui_set layerFilter description (#5776), which
replaces the earlier stopgap here. adjust_dialog.rs: a Smart Filter's
Curves/Levels editor reads its smart object's histogram, alongside main's
isolated mask or channel curve target (#5335).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:33 +03:00
abc2cbandClaude Opus 5.5 99cfcf40a7 Merge upstream/main (fd28da56) into Layers Panel options
server.rs takes main's ui_set layerFilter description (#5776), which
replaces the earlier stopgap here. es.tsv: main's Spanish catalog (#4306)
now has New Layer…, Panel Options… and Lock Layers, so this PR's copies
of those rows are dropped (bundled_catalogs_are_consistent).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:34:33 +03:00
Higgs32584 cae5a506c0 Merge main into fix/5727-move-tool-offset-readout 2026-10-14 23:31:29 -04:00
Gustavo Ziger b54675d8a1 Merge main into feat/zoom-animation-566 2026-10-15 00:30:40 -03:00
Higgs32584 48e12118d1 Merge main into fix/5806-displace-asks-for-map 2026-10-14 23:30:00 -04:00
Higgs32584 c394560d6a Merge main into fix/5863-cjk-menu-mnemonics 2026-10-14 23:29:25 -04:00
Khawar Zamman WaniandClaude Opus 5.5 8df66a71ca Layers filter tests: expand the fixture's group (#5571)
The fixture builds "Sheets" with layer.groupLayers, which now creates the
group collapsed. The unfiltered baseline lists only expanded groups'
contents (Photoshop), so open the group as the other group tests do. The
filtered checks already look inside collapsed groups (display_rows gets
show_all while a filter is active).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:50:32 +05:30
FazminandClaude a4b1e2a607 Type: Create warped text button in the options bar, as in Photoshop (#5911)
The Type tool's options bar now has Photoshop's Create warped text button
after the text colour swatch. It opens the same Warp Text dialog as
Type > Warp Text... for the selected type layers (or the layer being
edited), and is greyed when there is no type layer to warp.

- assets/icons/text-warp.svg: original glyph (a T above a bowed arc) on
  Lucide's 24 px grid and stroke; listed in ATTRIBUTION.md.
- The tooltip is translated in all 20 catalogs, reusing each language's
  existing "Create Warped Text" wording (new for Russian).

Test: options_bar_warp_button_warps_the_selected_type_layers.

Co-Authored-By: Claude
2026-10-14 23:09:05 -04:00
Khawar Zamman Wani e8f6b89c9c Merge remote-tracking branch 'origin/main' into fix/5571-group-layers-collapsed 2026-10-15 08:36:06 +05:30
Gustavo Ziger ad72d1d1fd fix(ci): repair preview tests and reduce WebAssembly size 2026-10-14 23:21:27 -03:00
Sir Starch 6dffa480ac BMP export: Indexed Color documents save as palette BMPs
Saving an Indexed Color document as BMP went through the ordinary RGB
path and wrote a 24-bit file, so the colour table was lost on reopen
(the PNG exporter already had a palette branch).

export_mode_specific now sends Indexed Color to BMP as well as PNG,
through a new encode_bmp_indexed that writes an 8-bit palette BMP with
the document's table. BMP has no transparency, so a transparent index
is written as its palette colour and the export says so.

bmp_palette returned the decoder's 256-entry padding, so a 3-colour BMP
reopened with 253 extra black entries. It now trims the table to the
colours the header declares.
2026-10-15 04:08:28 +02:00
Gustavo Ziger 1c926a558c Merge main into feat/zoom-animation-566 2026-10-14 23:06:43 -03:00
Michael Higgins 5b026c1979 Merge branch 'storytold:main' into fix/5806-displace-asks-for-map 2026-10-14 20:16:07 -04:00
Michael Higgins 17d0a55baf Merge branch 'storytold:main' into fix/5727-move-tool-offset-readout 2026-10-14 20:07:58 -04:00
Michael Higgins 6ee457cc7a Merge branch 'storytold:main' into fix/5745-auto-blend 2026-10-14 20:02:14 -04:00
Gustavo Ziger c62b355908 fix(automation): document ui.set layer filter 2026-10-14 20:51:08 -03:00
Gustavo Ziger 9724295a4e Merge remote-tracking branch 'origin/main' into feat/zoom-animation-566 2026-10-14 20:48:39 -03:00
Higgs32584andClaude Opus 5.5 772209808b Pen options bar: Make Selection in Path mode (#5703)
Photoshop's Pen options bar in Path mode offers "Make: Selection…". PhotoCraft
had it only on the canvas's right-click menu. The options bar now has a Make
Selection button (enabled when there's a path) that opens the same dialog for
the path being drawn, finished into the Work Path first, or the active path.
"Make Selection" gains its zh-Hans, zh-Hant and ko catalog rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:30:05 -04:00
Vikram Bhaskaran 21a9d13431 Prevent clipboard centering from overflowing document coordinates. 2026-10-15 10:23:24 +11:00
Vikram Bhaskaran ef449e2d8a Merge remote-tracking branch 'origin/main' into feat/iff-read
# Conflicts:
#	docs/gaps.md
2026-10-15 10:21:58 +11:00
Higgs32584andClaude Opus 5.5 b153c4e74e Menus: Chinese, Japanese and Korean labels get access keys, "文件(F)" (#5863)
Mnemonics (#4988) were assigned from the label as shown, so a label with no
Latin letter had none and its menu couldn't be opened with Alt+letter. Such a
label now shows its English label's letter in parentheses, before a trailing
ellipsis ("文件(F)", "新建(N)…"), and takes it as its mnemonic, as Windows
menus do in those languages. English and other Latin-script menus are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:18:26 -04:00
Higgs32584andClaude Opus 5.5 b54a4a8f86 Displace: OK asks for the displacement map, as in Photoshop (#5806)
Filter > Distort > Displace took its map only from an open document or a
path typed into a text field; OK without either ran with no map, and no file
picker ever appeared. Confirming the dialog with no map now opens the file
picker and runs Displace with the chosen file (smart filters included);
Cancel applies nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:54:11 -04:00
Gustavo Ziger 667ad683a5 Merge main into feat/zoom-animation-566 2026-10-14 19:38:43 -03:00
abc2cb ad236be126 Merge Layers Panel part 1 (ui_set layerFilter description fix) into codex/fill-mask-preview-20261012 2026-10-14 22:34:53 +00:00
abc2cb 49aba9f2bd Merge Layers Panel part 1 (ui_set layerFilter description fix) into codex/layers-subrow-scaling-20261012 2026-10-14 22:34:52 +00:00
abc2cb 0e009c246f Name layerFilter in the ui_set tool description
main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not,
so ui_set_description_names_every_accepted_field fails on ubuntu.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT
(cherry picked from commit 1d1cb4e462)
2026-10-14 22:34:50 +00:00
abc2cbandClaude Sonnet 5.5 1d1cb4e462 Name layerFilter in the ui_set tool description
main's UI_SET_FIELDS gained layerFilter (#5268) but the ui_set description did not,
so ui_set_description_names_every_accepted_field fails on ubuntu.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F8jU8SSgnSWm5L17agT9ZT
2026-10-14 22:34:47 +00:00
Finn Newick d770fd35f1 Merge remote-tracking branch 'origin/main' into fix/bundle-save-lock
# Conflicts:
#	crates/format/tests/roundtrip.rs
2026-10-14 23:12:45 +01:00
bazmarthandClaude Opus 5.5 8744820a27 Shapes: rounded corners on live triangles, polygons and stars (TOOL-214-12)
As Photoshop's rounded triangles and polygons, a live polygon (the Triangle
tool draws a 3-sided one, stars included) now has one corner radius:
- doc: LiveShape::Polygon gains corner_radius (serde default, so older
  .pcraft files load sharp);
- vector: shapes::fillet and shapes::round_corners round every vertex with a
  circular arc tangent to both edges, each capped so neighbouring arcs meet
  at most halfway along an edge;
- engine: shape.create / shape.edit take "cornerRadius"; transforms scale it
  like a rectangle's radii;
- ui: the Properties panel shows a Corner radius field for polygons, and the
  on-canvas corner widgets now cover every selected rectangle, triangle,
  polygon and star (Path Selection, the Rectangle/Triangle/Polygon tools,
  Move with Show Transform Controls). Dragging any widget gives every
  selected shape the same radius; Alt still changes one corner of a
  rectangle. The widgets take the unsnapped press: snapping used to pull a
  shape tool's press off the widget, so the tool drew a new shape instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:07:03 +00:00
Gustavo Ziger 6bb3130a4c fix: update zoom interruption for wheel navigation 2026-10-14 18:34:13 -03:00
Higgs32584andClaude Opus 5.5 a43f8b5ece Auto-Blend Layers: blend a layer the canvas crops to all-overlap (#5745)
Panorama seams skipped an image lying wholly inside the composite, so after
Auto-Align moved the second photo partly off the canvas it got an all-black
mask and nothing changed. Such an image now takes the side of the seam it
lies towards; blending covers the layers past the canvas; Seamless Tones and
Colors evens out exposure; the Background becomes a layer to take its mask.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:25:06 -04:00
abc2cbandClaude Opus 5.5 4499458d32 Merge Layers Panel part 1 (main 8d840eab) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:09:07 +03:00
abc2cbandClaude Opus 5.5 5cc5a9ed13 Merge Layers Panel part 1 (main 8d840eab) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:09:03 +03:00
abc2cbandClaude Opus 5.5 a40b95f250 Merge upstream/main (8d840eab) into Layers Panel options
Main landed Clip Thumbnails to Layer Bounds (#4281, #4312), the same
setting as this PR's Panel Options › Thumbnail Contents, and the Layers
panel filter-type dropdown (#5048, #5268):

- Thumbnails use main's frame and cache (layer_thumb, thumb_image); this
  PR's own Layer Bounds region code is gone. The Panel Options dialog's
  Layer Bounds / Entire Document now shows and sets
  interface.clipThumbnailsToLayerBounds, which the panel menu's checkbox
  toggles too, so the two can't disagree; it is no longer saved with the
  panel options.
- Filter Options › Show hides main's new filter bar.
- Catalogs keep main's hu/sr/tr/vi rows and this PR's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:03:05 +03:00
abc2cbandClaude Opus 5.5 d16b4f7302 Merge upstream/main (8d840eab) into Smart Filters editors
Resolve smart_ui.rs: a Filter Gallery smart filter opens main's gallery
window (#3426) before this PR's editor routing; catalogs keep main's
hu/sr/tr/vi rows and this PR's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:58:05 +03:00
timothydaly1andClaude Opus 5.5 2493eab205 Hue/Saturation: 32-bit saturation isn't clipped, its slider on whole levels
Photoshop's 32-bit Hue/Saturation raises the saturation by 1 / (1 - s)
like 8/16 bits, but doesn't clip it at 1: saturated colours leave 0..1
(the photoshop corpus's rgb32 hue-saturation.psd, Hue +40, Saturation
+25, Lightness -10, takes red to (1.0477, 0.649, -0.1477), where 8 bits
clip it to (0.9, 0.6, 0)). Its slider is cut to whole 8-bit levels: 25 %
raises by 1 / (1 - 63/255) = 1.328, not 1/0.75; in-gamut colours show
the same factor. The file becomes exact (0.1477 -> 0.0002).

hue_saturation_in carries the depth; the GPU kernel does the same under
F_HDR. Photoshop oracles 186 -> 187 (with the Colorize change, 185 ->
187), floor 187.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:57:16 -04:00
timothydaly1andClaude Opus 5.5 e41bc273fe Hue/Saturation: a 32-bit Colorize scales its saturation by 1 - |Lightness|
Photoshop's 32-bit Colorize builds its colour with less chroma than the
8/16-bit one: in the photoshop corpus's rgb32 hue-saturation-colorize
(hue -160, saturation 40, lightness +5) every output has the same hue
and lightness as ours but exactly 0.95 of the chroma, at every input
lightness from black to white. Scaling the saturation by 1 - |Lightness|
at 32 bits makes the file exact; 8 and 16 bits keep the full saturation
(their oracles are exact without it, and scaling there breaks them).
Only a raised Lightness is measured; a lowered one is taken to scale
the same way.

CPU (adjust::colorize_saturation) and the GPU planner alike. Photoshop
oracles 185 -> 186, floor 186.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:50:04 -04:00
vangel76andClaude Opus 5.5 47299e45af Merge upstream/main into feat/4391-contour-library
Keep both sides in commands.rs, lib.rs and the workspace sandbox list. Drop stale duplicate ja/hu rows left by the tsv union, add the contour strings to the new sr.tsv catalog, regenerate docs/scorecard.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:48:49 +02:00
vangel76andClaude Opus 5.5 9303c10bb6 Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:23 +02:00
timothydaly1andClaude Opus 5.5 06d38bce12 Lab: adjustment results are whole samples, as Photoshop stores them
The native Lab path (compose::native_lab) worked a stack out on the
stored L, a and b but kept each adjustment layer's result unrounded, so
the screen showed values between two samples where Photoshop, which
stores every result at the document's depth, has whole bytes (or
1/32768 steps). On saturated colours half a byte of L is a couple of
RGB levels. Each adjustment's result is now rounded to the depth's
samples (adjustment_quantum: 8 and 16 bits; 32-bit stays unrounded),
as the RGB compositor already does.

photoshop lab8 curves.psd goes from 0.0084 to exact (PASS); Photoshop
oracles 185 -> 186, floor 186. lab8 brightness-contrast (modern) stays
DIFF: our modern curve is within half a byte of Photoshop's on L, not
exact, so rounding now shows its misses (0.02 % -> 1.05 % of pixels).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:36:27 -04:00
midasmatterhorn f7109db763 Affinity import: budget decoded pixels across the document (#5599)
An Affinity container with many embedded images decoded (and kept) one
surface each with no cumulative cap, so a few-megabyte file could exhaust
memory: each image decodes up to 256 MB and resamples into another
surface of the same order, and nothing tracked the total.

The import now shares a 1 GiB decoded-pixel budget: an embedded image is
charged its decoded w×h×4 bytes, resampling is charged its staging
buffer, and an image the budget cannot cover is left out with a warning,
the same shape as the existing per-image caps. ORA and KRA budget their
decoded pixels the same way.

Regression tests cover the budget arithmetic and both charge sites (an
embedded PNG decode and an RGBA resample) with tiny in-memory fixtures.
2026-10-14 22:52:24 +03:00
Higgs32584andClaude Opus 5.5 097848ddb5 Move tool: ΔX/ΔY readout beside the pointer while dragging (#5727)
Only Free Transform drew a readout of how far the layer moved (its
Hit::Inside offset), so a Move-tool drag inside the transform controls,
or without them, showed nothing. Photoshop shows ΔX/ΔY for a Move drag.

The drag preview now draws the offset the release will commit
(move_ui::drag_offset, whole pixels), for a layer drag and a drag of
selected pixels, once the pointer has moved. It goes through
draw_value_readout, so Preferences > Tools > Show Transformation Values
hides it like the other readouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:50:36 -04:00
Ufuk Efe 02b3f0125a i18n(tr): comprehensive review, terminology refinement and UI consistency overhaul 2026-10-14 22:49:56 +03:00
Gustavo Ziger 48fe71fe15 Merge main into feat/zoom-animation-566 2026-10-14 16:46:24 -03:00
Florent Siegenthaler 196d5b4535 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks (the
duplicate "Content-Aware Fill" row is dropped).
2026-10-14 21:25:55 +02:00
Jason Xiang 3264c2fa6f fix(format): ignore accidental ZIP directory signatures in comments 2026-10-14 12:08:10 -07:00
Florent Siegenthaler 484b93b27f i18n(fr): drop the rewording of existing rows
Revert the effect of a23f7778b: every row that main already has is back to
main's text, the rows this branch adds keep the wording of the first commit,
and the header note recording the exception is removed. The catalog stays
clean-room as its header says.
2026-10-14 20:44:05 +02:00
Florent Siegenthaler 5f8e55dc57 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are, the row main removed stays removed, and the branch adds
only the keys main lacks.
2026-10-14 20:19:50 +02:00
Gustavo Ziger 1156279a99 Merge main into feat/zoom-animation-566 2026-10-14 15:19:25 -03:00
Finn NewickandClaude Opus 5.5 69cf9c00d3 Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:14:03 +01:00
Finn NewickandClaude Opus 5.5 bb8fc93908 Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
Batch and Image Processor params keep main's includeSubfolders and this branch's tiffCompression.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:12:44 +01:00
Gustavo Ziger db17e0072c Merge main into feat/zoom-animation-566 2026-10-14 15:09:28 -03:00
Finn NewickandClaude Opus 5.5 22eb15e814 Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:03:19 +01:00
Florent Siegenthaler 1115f4f129 Merge branch 'main' into fr-complete-catalog
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are, the three rows main removed stay removed, and the branch
adds only the keys main lacks.
2026-10-14 19:54:48 +02:00
Jason Xiang 5834d96003 fix(codecs): respect uncompressed DDS row pitch 2026-10-14 10:52:14 -07:00
lloydandClaude Opus 5.5 54e7287ba6 Animated WebP: every frame loads into video layers (#1198)
An animated WebP used to bring only its first frame into a video layer. Now
New Video Layer from File, Replace Footage, Reload Frame and Video Frames to
Layers take every frame, as they do for an animated GIF (#572):

- photocraft-codecs: `decode_webp_frames` returns each frame composited onto
  the canvas by image-webp (offsets, blending, disposal), with its duration in
  milliseconds and the file's ICC profile. A still WebP is one frame, read as
  `decode` reads it. A damaged later frame keeps the frames before it and marks
  the first `Truncated`; the frame count and total bytes are checked against
  `Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz
  target.
- photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop.
- photocraft-engine: `load_frames` takes an animated WebP's frames; the
  timeline rate comes from the most common duration. A still WebP keeps the
  existing path and the 30 fps default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:24:07 +02:00
Finn NewickandClaude Opus 5.5 02f99b138e Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
Keeps main's single format detection (file_encoding_as) with this branch's alpha_to_channel; progress docs keep both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:04:31 +01:00
augrclkandGPT-6 932d0689ca test(autosave): align retry coverage with upstream
Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 19:20:55 +03:00
augrclkandGPT-6 86d5d9c6f2 test(autosave): align retry coverage with upstream
Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 19:20:55 +03:00
augrclkandGPT-6 b851ca3417 test(autosave): align retry coverage with upstream
Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 19:20:54 +03:00
Reynold Hu dbd9a389b8 Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	crates/ui-egui/src/type_font_picker_tests.rs
#	docs/scorecard.md
2026-10-14 22:57:01 +08:00
Nigel Ihza Firdausy b51255f879 fix(i18n): keep upstream translations for duplicate catalog keys 2026-10-14 21:54:59 +07:00
Reynold Hu 50c99a7b77 Merge remote-tracking branch 'upstream/main' into agent-tasks-extended
# Conflicts:
#	docs/scorecard.md
2026-10-14 22:51:49 +08:00
Nigel Ihza Firdausy 1b142a4aa7 Merge current main and resolve reviewed PR conflicts 2026-10-14 21:42:34 +07:00
Nigel Ihza Firdausy 355146ccd2 Merge current main, preserve both translation sets, and cover Serbian shading labels 2026-10-14 21:42:11 +07:00
Nigel Ihza Firdausy b92f49a6cb Merge current main and retain reviewed PR changes 2026-10-14 21:34:33 +07:00
Khawar Zamman WaniandClaude Opus 5.5 70886c0d88 Layers: Group Layers creates the group collapsed (#5571)
Photoshop's Group Layers (Cmd/Ctrl+G) and Group from Layers create the new
group collapsed, so the Layers panel shows one row in place of the grouped
layers. layer.groupLayers and layer.new.groupFromLayers now do the same.
Layer::group still defaults to expanded, so New Group, PSD import and
artboards are unchanged.

Tests that walk group contents now open every group first with
layer.setExpanded {"all":true,"expanded":true}.

Closes #5571

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNoCkwuu6CgnxJAii7QExa
2026-10-14 19:57:02 +05:30
Florian MeyerandCodex 5f661d3ee3 fix(ui): keep type panels editable without a text layer
Persist Character and Paragraph formatting as Type tool defaults and apply it through type.create. Preserve selected-text scope, foreground color, undo, reset, and nonblocking font loading.

Fixes #5556

Co-Authored-By: Codex <[email protected]>
2026-10-14 15:39:02 +02:00
jonathnwxh 5635f23160 Merge upstream autosave retry test repair 2026-10-14 21:24:52 +08:00
Lorenzo Fasolino 5e57be89cf Merge remote-tracking branch 'origin/main' into task/T-0003 2026-10-14 13:49:20 +02:00
Lorenzo Fasolino 8ba6af8c84 Merge remote-tracking branch 'origin/main' into task/T-0001 2026-10-14 13:49:18 +02:00
jonathnwxh 5c9d2ce3ac Apply canvas display profiles to Free Transform previews 2026-10-14 19:43:03 +08:00
Khawar Zamman WaniandClaude Opus 5.5 20735ab521 Value fields: the mouse wheel steps them, as in Photoshop (#5473)
A field that has been clicked into takes the wheel wherever the pointer
is: field_wheel::begin_frame takes plain and Shift wheel events out of
the frame's input before anything is laid out, so the canvas and panels
neither scroll nor pan. Alt and Ctrl/Cmd + wheel stay the view's.

Over a field that hasn't been clicked, the wheel steps it too (as in
Photopea, #4061), but only for a gesture that starts over the field. A
panel being scrolled keeps scrolling when a field passes under the
pointer.

One step a notch, as the Up/Down keys: 1, or 0.01 for small ranges,
x10 with Shift. A typed sum is worked out first. Hovering never takes
focus, and a non-finite step is dropped. The slider wheel and the
fields share widgets::count_notches.

Closes #5473

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:06:45 +05:30
augrclkandGPT-6 b32563501d test(autosave): wait for the retried snapshot metadata
Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 14:12:45 +03:00
augrclkandGPT-6 ed76a4035b test(autosave): wait for the retried snapshot metadata
Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 14:12:44 +03:00
augrclkandGPT-6 23da649296 test(autosave): wait for the retried snapshot metadata
Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 14:12:44 +03:00
Finn NewickandClaude Opus 5.5 7fae49548d Merge remote-tracking branch 'origin/main' into fix/tiff-alpha-compression
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:34:37 +01:00
augrclkandGPT-6 25f12c5009 Merge upstream main into PR #5317
Keep both sets of progress notes and preserve the automatic source merge.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 11:33:35 +03:00
augrclkandGPT-6 b5528efc72 Merge upstream main into PR #5365
Keep both sets of progress notes and preserve the automatic source merge.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 11:33:34 +03:00
augrclkandGPT-6 16fb0f50e3 Merge upstream main into PR #5199
Keep both sets of progress notes and preserve the automatic source merge.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 11:33:32 +03:00
Vikram Bhaskaran 10220c97fc Translate New Action, Start Recording, and Stop Recording.
The Actions panel menu added those labels; complete-menu locales must cover every tl! literal.
2026-10-14 19:14:56 +11:00
Vikram Bhaskaran f784275180 Open Affinity files renamed .psd with the Affinity loader.
Magic 8BPS still goes to the PSD reader. A .psd suffix without that signature is PSD only after Affinity (and other native) magic is ruled out, so a renamed Affinity preview does not fail as an invalid PSD.
2026-10-14 18:34:48 +11:00
Vikram Bhaskaran a042bd373d Show Load Actions and the rest of the Actions panel menu.
The docked Actions panel menu already had Load/Save, but it omitted New Action, Play, Record, and Delete, so it looked like only docking commands. Match the Actions panel id in both casings and list the Photoshop-parity items.
2026-10-14 18:23:51 +11:00
Gert Pellin 1c7989f607 Merge remote-tracking branch 'upstream/main' into feat/4814-duotone-inks 2026-10-14 09:22:02 +02:00
Gert Pellin 2c5ed1c91d Layered TIFF corpus test: a flat Duotone is expected with its inks rendered
With the ink block read, an opened duotone has its real inks. A
single-layer one is saved flat, which writes the inks rendered as RGB
(flat.rs); the test compared that with the grey plate.

Refs #4814
2026-10-14 09:22:00 +02:00
Vikram Bhaskaran 478c986972 IO: open .psd/.psb/.psdt through the PSD reader, not codecs (#5258)
File › Open sent Photoshop extensions that lacked an 8BPS prefix (or
reached import by name) to photocraft-codecs, which reports
"unrecognized image format". Route them like other native formats, and
offer a Photoshop filter in the open dialog.
2026-10-14 18:04:46 +11:00
Gustavo Ziger e6d3c70835 Merge main into feat/zoom-animation-566 2026-10-14 03:56:28 -03:00
Finn NewickandClaude Opus 5.5 4c4138d6eb docs: TIFF alpha channels and compression (#5301)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:38:36 +01:00
Finn NewickandClaude Opus 5.5 4121ff4dc1 fix(tiff): keep a TIFF's alpha channel, and add a tiffCompression save option
Photoshop writes a document's alpha channel as an unspecified extra sample (ExtraSamples 0). The decoder dropped it, so such a TIFF opened without its channel and a save never wrote one. With no extra sample marked as transparency, the first unspecified one is now decoded as the alpha and flagged as an alpha channel (Metadata::alpha_is_channel): flat TIFFs open it as a named alpha channel over an opaque Background, layered ones through their Photoshop channel names. Saving writes the first alpha channel as an unspecified extra sample when the document has no transparency (flat and layered), naming the channels it leaves out; extra samples beyond the alpha are dropped with a warning. Saves also take tiffCompression (none, lzw, zip) and the CLI --tiff-compression. Fixes #5301.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:37:09 +01:00
Abdul karim mia c8ff8e3860 Add Color Table dialog translations 2026-10-14 10:58:39 +05:30
Finn NewickandClaude Opus 5.5 d658c13c4e fix(format): serialise directory bundle saves with a bundle-wide lock
A directory save lists the existing objects, writes its own, publishes its manifest and removes what that manifest doesn't reference. Two writers (two stores, or two processes) saving the same bundle could interleave, and one writer's cleanup could remove an object the other's winning manifest needs. Each save now holds an OS advisory lock on save.lock for its whole transaction; a held lock is waited for (10 s by default, PcraftWriter::set_lock_wait), then the save fails without writing. A crash releases the lock; a file system without file locks saves unlocked as before. Fixes #4957.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 06:12:49 +01:00
Abdul Karim Mia fef5e042bd Support .act color tables end to end (swatches + Color Table dialog)
Closes #1127. Photoshop's .act (256 RGB triplets, optional count/
transparent trailer) now loads everywhere swatches do: a new
photocraft-psd act parser/writer (byte-stable round trip, hostile
lengths are errors), Format::Act in swatches.import/export with
length/extension sniffing, the Swatches panel menu, File > Open
(desktop + web filters, drag-and-drop) and Load/Save buttons in
Image > Mode > Color Table. Verified against a Photoshop-saved
table (256 colors, byte-identical re-export).
2026-10-14 10:35:30 +05:30
Abdul karim mia 007581b7a4 Remove feature changes from contributor credit PR 2026-10-14 10:12:26 +05:30
Abdul karim mia 03b6af0c12 Keep contributor credit PR focused on contributor entry 2026-10-14 10:12:24 +05:30
Abdul Karim Mia fb3997514f Credit Abdul karim mia in contributors 2026-10-14 07:59:57 +05:30
Abdul Karim Mia ce383de7d8 Map .atn duplicate-into-new-document (Mk) to layer.duplicate
Photoshop records Layer > Duplicate Layer with Destination: New as a
Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy
name), verified against a ScriptListener/XTensions export. Import maps
it to layer.duplicate {document: new}; export writes it back, so the
steps round-trip instead of becoming kept Photoshop steps that fail at
playback. A Dplc with a T document reference (another open doc) now
imports as a kept step rather than misplaying as a same-doc copy.
2026-10-14 07:55:29 +05:30
augrclkandGPT-6 61b0f4699f Brush Settings: show keyboard focus on section locks
Co-authored-by: GPT-6 <[email protected]>
2026-10-14 05:12:20 +03:00
augrclkandGPT-6 17abae9741 Brush Settings: expose section lock names and state
Co-authored-by: GPT-6 <[email protected]>
2026-10-14 04:52:47 +03:00
Gustavo Ziger e14bd18360 test: update Navigator Animated Zoom regression 2026-10-13 20:03:00 -03:00
augrclkandGPT-6 811a40bda2 Brush Pose: preserve independent tilt-axis overrides
Keep X/Y flags through ABR import, presets, commands and painting; retain legacy combined inputs and expose both controls with regression coverage.

Co-authored-by: GPT-6 <[email protected]>
2026-10-14 01:47:21 +03:00
webdevtodayjason 0ac13b43c8 Merge remote-tracking branch 'upstream/main' into pcx-runs-across-planes
# Conflicts:
#	contributors/people.toml
2026-10-13 17:41:13 -05:00
Lorenzo Fasolino c09aff2517 Merge remote-tracking branch 'origin/main' into task/T-0001 2026-10-13 22:46:09 +02:00
Lorenzo Fasolino bc38bf15be Merge remote-tracking branch 'origin/main' into task/T-0003 2026-10-13 22:45:28 +02:00
vangel76 2b3df66ff0 Merge remote-tracking branch 'upstream/main' into m/x
# Conflicts:
#	ATTRIBUTION.md
2026-10-13 21:55:40 +02:00
vangel76 1e4d41be0a Merge remote-tracking branch 'upstream/main' into m/c1 2026-10-13 21:54:21 +02:00
Gert Pellin 186dbfe4cd Merge branch 'feat/4814-psd-palette-modes' into feat/4814-duotone-inks 2026-10-13 21:47:44 +02:00
Florent Siegenthaler d306c539c9 Merge branch 'main' into fr-complete-catalog
Resolve the conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows are
kept as they are, and the branch adds only the keys main lacks (the duplicate
"Folder" row is dropped).
2026-10-13 21:21:06 +02:00
Vikram Bhaskaran 085c29bea7 Wrap OPEN_EXTS so cargo fmt --check passes.
CI failed rustfmt on the DICOM extension lines in the desktop and web open-file lists.
2026-10-14 05:50:25 +11:00
Gustavo Ziger 1105b5da6a Merge main into feat/zoom-animation-566 2026-10-13 15:48:48 -03:00
abc2cbandClaude Opus 5.5 08717e7bcf Merge Layers Panel part 1 (main 99e11388) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:39:27 +03:00
abc2cbandClaude Opus 5.5 8313dadcc5 Merge Layers Panel part 1 (main 99e11388) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:39:05 +03:00
abc2cbandClaude Opus 5.5 014207fa51 Merge upstream/main (99e11388) into Layers Panel options
Picks up main's rustfmt fix for rotate_view.rs and wheel_nav.rs, which
failed the Format step at 74cb2935.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:39:01 +03:00
augrclkandGPT-6 69cd2abafc Brush Settings: disable Wetness/Mix controls outside Mixer Brush
Pass the active tool to the section renderer and disable the complete
Wetness/Mix Transfer blocks when they do not apply, preserving saved settings.
Cover all themes/tools, disabled input, active edits and real tool-cache swaps
with three regressions, including exact tools.setBrush journal routing.

Addresses the Wetness/Mix tool-applicability subsection of #3825.

Co-authored-by: GPT-6 <[email protected]>
2026-10-13 21:19:24 +03:00
Vikram Bhaskaran ab596db6ae Codecs: open Amiga IFF/ILBM bitmaps read-only
Amiga IFF/ILBM (EA 85) artwork opens read-only: the FORM chunks BMHD,
CMAP and BODY, both the interleaved ILBM and planar PBM variants, 1-8
bitplanes through the palette (a grey ramp when there is none), masking
0/1/2, and both stored (0) and ByteRun1/PackBits (1) compression. Chunk
lengths and Run lengths are checked, so a malformed file errors rather
than panicking; other bit-plane counts return Unsupported. No writer, so
.iff/.lbm/.ilbm join the open filters and the format opens in Photoshop
parity's Amiga IFF row.
2026-10-14 05:09:01 +11:00
Fabio Lissi 9e953aa897 Merge remote-tracking branch 'origin/main' into feat/atn-corpus 2026-10-13 13:00:18 -05:00
abc2cbandClaude Opus 5.5 dc0aba4be2 Merge upstream/main (99e11388) into Smart Filters editors
Resolve filter_dialog.rs: main's Blur Gallery pin scaling for proxy
previews (#3801) moves into scale_preview_params, which this PR uses for
every preview, so pins also scale when Blur Gallery runs as a Smart
Filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:33:41 +03:00
vangel76 9cd9049c73 Merge remote-tracking branch 'upstream/main' into m/c1
# Conflicts:
#	crates/automation/src/workspace.rs
2026-10-13 19:21:26 +02:00
Gustavo Ziger e0b0b53946 Merge main into feat/zoom-animation-566 2026-10-13 14:18:15 -03:00
zolyyandClaude Opus 5.5 c0faf7300c Type tool: click a path to start type on it (TYPE-218-1, #218)
A Type tool click on a shown path (the active shape layer's, else the work
path) starts type on that path at the point clicked, as in Photoshop: one
history step, placeholder selected, the paragraph alignment kept about the
click. Over such a path the pointer is the path-type I-beam, and the Type
tool shows the paths as the vector tools do.

- text: warp::arc_length_at, the arc length of the nearest point on a path.
- engine: type.attachToPath takes "at": [x, y] (a document point) instead
  of "start".
- ui: the caret, selection and underline follow the curve; a click in path
  type puts the caret at the glyph there; glyphs past the path end are not
  highlighted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:11:06 +03:00
abc2cbandClaude Opus 5.5 aef99241eb Merge Layers Panel part 1 (main 74cb2935) into codex/fill-mask-preview-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:05:14 +03:00
abc2cbandClaude Opus 5.5 8d9668bf87 Merge Layers Panel part 1 (main 74cb2935) into codex/layers-subrow-scaling-20261012
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:05:14 +03:00
abc2cbandClaude Opus 5.5 82ea23e9a3 Merge upstream/main (74cb2935): keep main's hu/sr/tr rows and this PR's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:55:40 +03:00
abc2cbandClaude Opus 5.5 f43f9df2d7 Merge upstream/main (74cb2935): keep main's hu/sr/tr rows and this PR's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:55:39 +03:00
abc2cbandClaude Opus 5.5 dfb44a5e3e i18n(sr): translate Use Default Masks on Fill Layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:54:43 +03:00
abc2cbandClaude Opus 5.5 e86852ac83 Merge Layers Panel part 1 (main 523f1f06, Serbian rows) into Fill layer masks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:52:05 +03:00
abc2cbandClaude Opus 5.5 288f85453b Merge Layers Panel part 1 (Serbian rows) into sub-row scaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:52:02 +03:00
abc2cbandClaude Opus 5.5 12ec8fceea i18n(sr): translate this PR's strings in the new Serbian catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:51:53 +03:00
abc2cbandClaude Opus 5.5 6d37936db3 i18n(sr): translate this PR's strings in the new Serbian catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:51:50 +03:00
abc2cbandClaude Opus 5.5 a7ce975694 Merge Layers Panel part 1 (main 523f1f06) into sub-row scaling
Resolve smart_ui.rs: main's filter row selection, drag and reorder
(#4371, #4764, #4897) keep working on rows sized by this PR (compact
sub-rows, the Smart Filters header and its mask follow the thumbnail size);
this PR's scaling test sits next to main's inline unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:50:26 +03:00
abc2cbandClaude Opus 5.5 7505ebdae3 Merge upstream/main (523f1f06) into Layers Panel options
Resolve the conflicts by keeping both sides: the live reorder preview next
to main's Content-Aware Move preview (canvas.rs), the Layers panel options
dialog next to main's rasterize prompt (lib.rs), the thumbnail size test
next to main's lock icon tests (layer_row_ui/tests.rs), and main's hu/tr/vi
rows plus this PR's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:47:43 +03:00
abc2cbandClaude Opus 5.5 641155b3bc Merge upstream/main (cdfec72f) into Smart Filters editors
Resolve the conflicts with main's smart filter row selection, drag and
reorder (#4371, #4897), channel-targeted filter previews and the new
hu/tr/vi rows:

- smart_ui.rs: filter rows keep this PR's layout (name and Blending
  Options icon as separate targets, indents) and take main's click to
  select, drag and drag-to-reorder; the name forwards them to the row.
  This PR's widget tests move to smart_ui/editor_tests.rs next to main's
  inline unit tests.
- filter_dialog.rs, canvas.rs: the smart filter preview scaling and
  smart_preview_factor run inside main's preview_document_for_target,
  with its channel target and source documents.
- i18n: main's rows plus this PR's; hu rows use informal address (#4334).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 19:39:11 +03:00
Lorenzo Fasolino b1bf337616 Merge remote-tracking branch 'origin/main' into task/T-0001 2026-10-13 18:31:27 +02:00
Lorenzo Fasolino b470dcab2d Merge remote-tracking branch 'origin/main' into task/T-0003 2026-10-13 18:31:27 +02:00
Lorenzo Fasolino 25541891d1 fix(i18n): keep the Italian Dodge tool as Scherma; only Screen was wrong
Review feedback on #5062: Adobe's Italian Photoshop calls the Dodge tool "Strumento Scherma" (Burn is "Strumento Brucia") and "Schiarisci" is the Lighten blend mode. Renaming the tool to Schiarisci would have given two different things the same name and split the Dodge/Burn/Sponge family the catalog already had (Scherma, Brucia, Spugna). Only the Screen blend mode changes: it read "Schermo" (the display) and is now "Scherma". The test pins the tool family and the blend modes.
2026-10-13 18:31:27 +02:00
Gustavo Ziger 41a75d8a1c Merge main into feat/zoom-animation-566 2026-10-13 13:24:20 -03:00
Lorenzo Fasolino c3f0e407fc Merge main into task/T-0001, keeping both contributors entries
main gained a contributors/people.toml entry ([people.yeniufuklar-com]) while this PR added one ([people.LorenzoFasolino]); both are kept. The i18n change merged cleanly.
2026-10-13 18:17:16 +02:00
Nigel Ihza Firdausy 66ebd86873 Merge current main after toolbar flyout port 2026-10-13 22:58:35 +07:00
Nigel Ihza Firdausy 7fc8b5d8b4 fix(i18n): translate pending screen capture message in all catalogs 2026-10-13 22:57:40 +07:00
Nigel Ihza Firdausy b314f9c565 Merge upstream main and restore toolbar press-drag flyout selection
Port grouped-tool press-drag-release onto the current panels toolbar,
preserving pointer-outside dismissal and long-press behavior. Add
an egui interaction regression plus boundary cases for gesture pickup.
2026-10-13 22:57:39 +07:00
Lin Heyang 08d14079bd i18n: translate missing Simplified Chinese tool labels
## Summary

- Translate `Vertical Type Tool` as `直排文字工具`
- Translate `Pencil Tool` as `铅笔工具`
- Translate `Color Sampler Tool` as `颜色取样器工具`

These labels previously fell back to English in the Simplified Chinese UI.
2026-10-13 23:48:38 +08:00
Lorenzo Fasolino b4664381ed chore(contributors): credit LorenzoFasolino as Lorenzo Fasolino
Contributors entry for the About window: real name and display name. The public GitHub profile name is empty, so sync_github_name stays false rather than falling back to nothing.
2026-10-13 16:16:05 +02:00
Lorenzo Fasolino dde4b8dca8 docs(perf): how to measure one scenario and read its numbers
Task T-0003 del lab artcraft-lab.

File toccati:
   docs/development.md | 34 ++++++++++++++++++++++++++++++++++
   1 file changed, 34 insertions(+)

Evidenze: artifacts/T-0003/ (nel lab, non in questo repo).
2026-10-13 16:15:28 +02:00
Lorenzo Fasolino 5035aa4116 fix(i18n): Italian Screen blend mode and Dodge tool names were swapped
Task T-0001 del lab artcraft-lab.

File toccati:
   crates/ui-egui/src/i18n/it.tsv |  8 ++++----
   crates/ui-egui/src/i18n/mod.rs | 16 ++++++++++++++++
   docs/localization-it.md        |  9 +++++++++
   docs/localization-parity.md    |  1 +
   4 files changed, 30 insertions(+), 4 deletions(-)

Evidenze: artifacts/T-0001/ (nel lab, non in questo repo).
2026-10-13 16:15:27 +02:00
Gustavo Ziger 7f72cb8156 Merge main into feat/zoom-animation-566 2026-10-13 10:32:21 -03:00
Fabio Lissi 74ad8ee5bc Pin a corpus of real Photoshop Actions sets and test the .atn parser and import on it (#219)
- corpus/atn: 16 sets from seven public repositories (MIT, BSD, Unlicense), fetched at pinned
  commits by cargo xtask corpus --atn, verified against xtask/atn-corpus.sha256, licences next to
  the files, rows in ATTRIBUTION.md and docs/development.md. photocraft-corpus takes only our own
  generated files, so third-party sets are pins here, like psd-tools and ag-psd.
- photocraft-psd corpus_atn: every set parses and rewrites byte for byte (floor 16 files).
  photocraft-engine corpus_atn: every set loads, saves and reloads to the same actions; the
  steps still kept as Photoshop steps are capped (517 of 5 296 measured; generative AI and
  scripts), and the first differing step is named on a mismatch.
- What the corpus found: localised panel names are platform legacy bytes (Mac Roman), now kept
  verbatim with AtnItem::label from the descriptor's Unicode class name; exporters emitted
  Photoshop defaults the importer had not read (now symmetric); setAdjustment carries its kind;
  and 300 more steps map: transform without a reference, selection nudges, moves to the bottom,
  View menu items, toolbox colours, clipping masks, ungroup, copy/paste styles, collapse groups,
  layer comps. New example atn_roundtrip shows the first differing byte.
2026-10-13 08:29:38 -05:00
webdevtodayjason 1e6a9f27b9 Codecs: PCX runs may cross plane boundaries within a scanline 2026-10-13 07:15:13 -05:00
webdevtodayjason 6f3db89321 Contributors: add Jason Brashear 2026-10-13 07:15:13 -05:00
Nigel Ihza Firdausy b2fbdb1c2d fix(ui): deselect layers when clicking outside the canvas (#4923) 2026-10-13 18:12:49 +07:00
Gert Pellin 772cf39c09 PSD Duotone: read and write the ink block (names, colours, curves)
The Duotone Color Mode Data is undocumented. From files Photoshop 2025
made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as
in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four
transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20,
..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain
and eleven overprint colours.

- Import interprets the inks, so an opened duotone shows them instead of
  its grey plate; the block is still kept raw and written back while the
  inks are unedited. A colour-book ink (not readable here) shows as black
  with a warning.
- Export writes a block for duotones made in PhotoCraft (RGB ink colours,
  curves on the 13-point grid or sampled at it, default dot gain,
  computed overprints), so every 8/16-bit Duotone is saved as Duotone.

Photoshop opens such files as Duotone, and a black monotone whose curve
is 0 up to 60 % density stays white exactly up to 60 % there.

Refs #4814
2026-10-13 12:19:37 +02:00
Fabian Ghenof 898f4a789a Merge remote-tracking branch 'upstream/main' into fix/move-shape-keeps-rendering
# Conflicts:
#	crates/engine/src/vector_cmds/tests.rs
2026-10-13 09:34:07 +03:00
Przemek c99df31a13 SVG import: set text in an installed font when the named ones are missing
usvg's fontdb maps the generic families to Windows fonts (sans-serif to
Arial, serif to Times New Roman, monospace to Courier New) and knows
nothing of Fontconfig's aliases. On a Linux without those fonts (Fedora
44 ships neither Arial nor DejaVu Sans), every family of a usual
font-family="Arial, Helvetica, sans-serif" misses and usvg drops the
text, although hundreds of fonts are installed; the import then only
says "nothing to draw", because the no-font warning (#3168) fires only
for an empty font database.

Point each generic family at an installed font, as a browser falls
back: the system's choice (fc-match on Linux) when the database has it,
then metric-compatible and common stand-ins (Liberation, Croscore,
DejaVu, Noto), the bundled craft fonts, the sans-serif choice, and any
font at all. A generic whose font is installed is left alone.

The tests use the bundled Inter only, so they don't depend on the
fonts of the machine; tests/svg.rs's text test now passes on Fedora.
2026-10-13 08:22:25 +02:00
Sean Lavery 5af951d2d9 Merge current upstream main into TIFF type fix 2026-10-12 22:47:40 -07:00
Sean Lavery ec56c286dd Fix mixed-order TIFF type transforms and reject unsafe geometry 2026-10-12 22:39:20 -07:00
Vikram Bhaskaran 7b8e21662e Merge origin/main into the DICOM reader; fix the review findings
Address @echelon's review:
- Interleaved/planar sample indexing no longer double-counts the channel and
  uses checked arithmetic and get(), so RGB/YBR cannot index past the pixel
  data; RGB and YBR_FULL require exactly three samples per pixel.
- The transfer syntax is read from (0002,0010) exactly, and the meta walk
  stops at group 0002 (the previous code matched on a literal element number
  and validated the whole file as explicit VR LE, breaking implicit VR and
  big-endian files).
- Undefined-length sequences outside the pixel data are skipped (nested items
  and sequences tracked); encapsulated pixel data is refused.
- Tests added for RGB interleaved and planar, a multi-element meta group, and
  implicit VR.
2026-10-13 16:00:48 +11:00
Nigel Ihza Firdausy 6c7b9d879d fix(screen-picker): allow cancelling pending captures and avoid stale answers 2026-10-13 11:57:35 +07:00
Nigel Ihza Firdausy 5d753525cc fix(screen-picker): allow cancelling pending captures and avoid stale answers 2026-10-13 11:57:30 +07:00
Gustavo Ziger 55caab53ce Merge main into feat/zoom-animation-566 2026-10-12 23:26:31 -03:00
abc2cbandClaude Opus 5.5 3fbdc1a947 i18n: translate Use Default Masks on Fill Layers in hu, tr and vi
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:11:38 +03:00
abc2cb 224af59267 Merge Layers Panel part 1 (main + hu/tr/vi translations) into fill masks 2026-10-13 01:11:20 +03:00
abc2cb b6fece801c Merge Layers Panel part 1 (main + hu/tr/vi translations) into sub-row scaling 2026-10-13 01:11:18 +03:00
abc2cbandClaude Opus 5.5 8bc312ac59 i18n: translate the Smart Filter editor strings in hu, tr and vi
Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was
written, so every_tl_literal_is_translated fails on the merged tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:11:08 +03:00
abc2cbandClaude Opus 5.5 6bf45d05ac i18n: translate the Layers Panel strings in hu, tr and vi; drop Italian duplicates
Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was
written, so every_tl_literal_is_translated failed on the merged tree. Main
also translated New Layer…, Panel Options… and Lock Layers in Italian
(#3742); drop this branch's copies, which bundled_catalogs_are_consistent
rejects as duplicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:10:38 +03:00
abc2cb 2a54c3a573 Merge upstream main into Smart Filters editors 2026-10-13 01:10:05 +03:00
abc2cb 7fc98eb769 Merge upstream main into Layers Panel part 1 2026-10-13 00:58:53 +03:00
vangel76andClaude Opus 5.5 5484591dc2 docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:49:20 +02:00
vangel76andClaude Opus 5.5 9f1d4760fa Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:49:19 +02:00
Florent Siegenthaler a23f7778be i18n: align French terms with retouching and Photoshop vocabulary (366 entries)
Rewords French entries, old and new, to the terms French retouchers know: Couche for Channel, Zone de travail for Canvas, Masque de fusion, Objet dynamique, Pixelliser, Calque de réglage, Contour progressif, Tampon de duplication, Transformation manuelle, Tons clairs / Tons foncés, Masque d'écrêtage. 88 entries use terms attested in Adobe's public French help pages; 278 come from the reviewer's knowledge of the French interface. The catalog header records the exception to the clean-room note. Keys with two meanings (Fill, Canvas, Screen, Sharpen) are left unchanged.
2026-10-12 23:17:21 +02:00
Florent Siegenthaler fffa54068c i18n: complete the French (fr) UI translation (1,041 entries)
Adds the 1,041 keys that other catalogs translate and fr.tsv lacked, so French covers the union of all catalog keys. New entries reuse the vocabulary the French catalog already uses (Canal, Rastériser, Canevas, Masque de calque, Objet intelligent); placeholders, edge spaces, trailing ellipses and the two French plural forms are preserved.
2026-10-12 23:17:21 +02:00
abc2cbandClaude Opus 5.5 9323037883 Keep Smart Filter and effect rows compact at every thumbnail size
Photoshop keeps the Effects, effect and Smart Filter rows at their compact
height and font at every Layers panel thumbnail size; only the layer row
grows, and the Smart Filters header grows just enough for its shared-mask
thumbnail, which follows the thumbnail size (hidden at None).

Previously every sub-row took the layer row height and a 13 pt font at Large,
so sub-rows outsized the layer name. Keep sub-rows at 22 px, 11.5 pt and a
12 px eye; scale only the Smart Filters header and its mask. Effect rows are
back to main's layout. The test checks both and can save renders with
PHOTOCRAFT_LAYERS_SNAPSHOTS=<dir>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:09:28 +03:00
vangel76andClaude Opus 5.5 b2f67ef31f Automation: classify contour library load/save paths in the workspace sandbox (#4391)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:02:41 +02:00
vangel76andClaude Opus 5.5 99ba7f2688 Layer Style: user contour library with New…, Load/Replace/Save Contours (#4391)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 20:53:16 +02:00
Nigel Ihza Firdausy 5f0e7489e2 fix(layer-style): complete bevel shading controls and sync main
Fix the effect-defaults delimiter and preserve independent bevel shading on partial edits. Add the six dialog labels to all current translation catalogs and retain the UI/engine regression tests. Integrate upstream main to address review feedback.
2026-10-13 01:31:07 +07:00
Gustavo Ziger ab15f6e60c Merge main into feat/zoom-animation-566 2026-10-12 15:15:59 -03:00
Vikram Bhaskaran 89791873f9 Codecs: open uncompressed DICOM single-frame images (#1048)
Add a pure-Rust DICOM reader: the file meta group's transfer syntax
(implicit VR little-endian, explicit VR little/big-endian), MONOCHROME1/2
and RGB/YBR_FULL, 8/16-bit allocated samples, interleaved or planar,
BitsStored/HighBit scaling and signed pixel representation. Compressed
syntaxes, sequences, multi-frame data and other photometric interpretations
are refused with Unsupported/Malformed, never wrong pixels. DICOM is
read-only; .dcm/.dicom/.dc3 join the open filters (and .pcx is added to the
web filter, where it was missing).
2026-10-13 03:24:57 +11:00
Gustavo Ziger 8c15481091 fix: hook Animated Zoom preference 2026-10-12 11:20:45 -03:00
Gustavo Ziger 62fe3ed69b Merge main into feat/zoom-animation-566 2026-10-12 10:58:53 -03:00
Gustavo Ziger 07b5a0bd6a docs: restore History snapshots parity entry 2026-10-12 10:50:48 -03:00
Reynold Hu e25b02ee5e Merge remote-tracking branch 'upstream/main' into agent-tasks-extended 2026-10-12 20:41:19 +08:00
Reynold Hu 7390a8a9aa Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	docs/scorecard.md
2026-10-12 20:41:14 +08:00
Reynold Hu 76b36f58f7 Merge remote-tracking branch 'upstream/main' into type-recent-fonts 2026-10-12 20:07:02 +08:00
Reynold Hu e2fa116950 Merge remote-tracking branch 'upstream/main' into agent-tasks-extended 2026-10-12 20:06:57 +08:00
Reynold Hu a36b365fa5 scorecard: FILE-216-6 note names all three task files (36 MCP tasks) 2026-10-12 18:59:13 +08:00
Reynold Hu 2448c4ebea Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	docs/scorecard.md
2026-10-12 18:58:00 +08:00
Reynold Hu 3968eccaf0 Merge remote-tracking branch 'upstream/main' into agent-tasks-extended
# Conflicts:
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-12 18:57:49 +08:00
mroxso d1a795edde Merge branch 'storytold:main' into main 2026-10-12 12:27:24 +02:00
vangel76andClaude Opus 5.5 3454c40fa7 Merge upstream/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:16:34 +02:00
abc2cb f3848ed6c8 Merge branch 'codex/github-layers-panel-maintenance-20261011' into codex/fill-mask-preview-20261012 2026-10-12 08:23:25 +03:00
abc2cb c9f986057c Merge branch 'codex/github-layers-panel-maintenance-20261011' into codex/layers-subrow-scaling-20261012 2026-10-12 08:23:25 +03:00
abc2cb cfe712c340 Apply panel copy-name setting to the new artboard duplication path 2026-10-12 08:22:30 +03:00
abc2cb e1b2d49973 Honor default fill masks consistently in creation and Pattern Fill preview 2026-10-12 08:19:25 +03:00
abc2cb 475a0745d8 Scale Smart Filter and effect rows with Layers thumbnails 2026-10-12 08:19:25 +03:00
abc2cb 44c52a013a Keep Layers Panel menu and options; extract sub-row scaling and fill masks 2026-10-12 08:17:55 +03:00
abc2cb a42e4f9a2d Merge main while preserving panel options and current engine behavior 2026-10-12 08:14:33 +03:00
abc2cb 3ce9b2e0b8 Merge main after removing auxiliary CI repairs 2026-10-12 08:14:32 +03:00
abc2cb 3ad6011bc5 Remove shared main repairs from Layers Panel feature scope 2026-10-12 08:10:48 +03:00
abc2cb a33b7929a0 Remove shared main repairs from Smart Filters feature scope 2026-10-12 08:10:47 +03:00
Gustavo Ziger cf7120bd46 fix(ci): move zoom tests after canvas production code 2026-10-12 02:08:32 -03:00
Gustavo Ziger a4aa9a2b2a Merge branch 'main' into feat/zoom-animation-566 2026-10-12 01:45:56 -03:00
Gustavo Ziger f15cc03f99 Merge main into feat/zoom-animation-566 2026-10-12 01:38:58 -03:00
Fabian Ghenof 7d9d1384cb Merge remote-tracking branch 'upstream/main' into fix/move-shape-keeps-rendering
# Conflicts:
#	crates/engine/src/vector_cmds/tests.rs
2026-10-12 07:24:18 +03:00
Nigel Ihza Firdausy 22f8520974 feat(layer-style): expose Bevel & Emboss highlight and shadow shading 2026-10-12 09:00:08 +07:00
Gustavo Ziger 5c51510c51 Merge main into feat/zoom-animation-566 2026-10-11 22:46:07 -03:00
Ahmed Tammam 474a4375eb feat(transform): add 9-point reference point anchor widget to options bar
Add Photoshop's classic 3x3 reference point grid to the Free Transform options bar, enabling quick anchor point selection (corners, edge midpoints, and center). Snaps pivot using projective homography to maintain exact reference positioning across scale, skew, and perspective adjustments.
2026-10-12 04:30:26 +03:00
Reynold Hu d58da290df Merge remote-tracking branch 'upstream/main' into agent-tasks-extended
# Conflicts:
#	docs/scorecard.md
2026-10-12 07:56:40 +08:00
Reynold Hu 77a67a5ea5 Merge remote-tracking branch 'upstream/main' into type-recent-fonts
# Conflicts:
#	crates/ui-egui/src/type_panels_ui.rs
#	crates/ui-egui/src/type_tool.rs
#	docs/scorecard.md
2026-10-12 07:56:10 +08:00
scott 8afe7157ed docs(control): state the document.pixel / document.sampleColor reply range (#3666)
Both reply with four RGBA floats in 0..1, not 0..255, but control-protocol.md
gave a range for neither: the Engine commands table row for document.pixel and
the Methods line for document.sampleColor. A client that assumes 0..255
mis-reads every probe.
2026-10-11 16:16:08 -05:00
Gustavo Ziger aa57cc80b9 feat: animate zoom changes (#566) 2026-10-11 17:53:22 -03:00
abc2cb b8a7c93b4f Merge current main and retain both sets of translations 2026-10-11 23:31:43 +03:00
abc2cb 6548e1c194 Merge current main and retain both sets of translations 2026-10-11 23:31:42 +03:00
abc2cb 28a3514a73 Deny ambient installed profile discovery in automation 2026-10-11 23:29:29 +03:00
abc2cb 69b1f2544d Deny ambient installed profile discovery in automation 2026-10-11 23:29:29 +03:00
abc2cb 56030984b3 Merge main, deduplicate layer labels and optimize browser command code 2026-10-11 23:00:40 +03:00
abc2cb 0364da6ae9 Merge main and optimize browser command code for size 2026-10-11 23:00:40 +03:00
abc2cb 8911a4ffbe test(engine): expect signed HDR outputs from destructive Levels 2026-10-11 22:26:22 +03:00
abc2cb 05af9b146b test(engine): expect signed HDR outputs from destructive Levels 2026-10-11 22:26:13 +03:00
abc2cb 849d064cc8 Merge current main into Layers Panel PR and align Pattern Fill previews 2026-10-11 22:13:58 +03:00
abc2cb 32b1ae2e5a Merge current main into Smart Filters PR and preserve both preview paths 2026-10-11 22:13:58 +03:00
vangel76andClaude Opus 5.5 958af5f518 Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:54:58 +02:00
vangel76andClaude Opus 5.5 8607654fa7 Merge upstream/main into fix/3490-move-align-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:33:05 +02:00
Reynold Hu f5c6e428f2 docs(scorecard): FILE-216-6 counts the second acceptance file 2026-10-11 23:19:34 +08:00
Reynold Hu 8781937ae2 test(automation): tasks 21-30 in a second acceptance file 2026-10-11 23:19:34 +08:00
Reynold Hu 2042a0ffb3 docs(scorecard): settings that do nothing 31 of 154 2026-10-11 23:12:16 +08:00
Reynold Hu 6293fd87eb feat(ui): hoist recent families in the font menu (#204) 2026-10-11 23:12:16 +08:00
Reynold Hu 5aa9a1efae feat(ui): track recently used type families (#204) 2026-10-11 23:12:16 +08:00
vangel76andClaude Opus 5.5 0a75c37c7a Move tool: show all six Align icons and an icon menu for Distribute (#3490)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:44:26 +02:00
Fabian GhenofandClaude Opus 5.5 c17cc118f5 Moving a shape keeps its rendering: Artboards to Files matches Photoshop
translate_vectors re-rendered a shape layer on every move. For a shape from a
PSD that replaced Photoshop's anti-aliasing with PhotoCraft's, so Artboards to
Files (which moves each board to the origin), the Move tool, nudges, layer
comps and moving artboards changed how imported shapes look.

A whole-pixel move now shifts the rendered pixels, through one shift_shape
helper shared with the Move tool's live preview (layer_multi_cmds::
shift_shown), which already did this. The render is cut at the canvas, so a
shape reaching past it still renders again, as does a fractional move; the
path moves either way, and fit_canvas still re-renders shapes when an
artboard move grows the canvas (#2389).

Measured on a Steam store asset template with #3218 (the same fix for type):
the "How to use" artboard has no pixel off by more than 2 levels from
Photoshop's own composite; before, both its shapes differed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:11:48 +03:00
abc2cb 338304e712 Merge upstream main into Layers Panel PR, preserving docking, layer moves and localized options 2026-10-11 10:54:25 +03:00
abc2cb 1515cafae4 fix(engine): retain Smart Filter coverage around blur edges 2026-10-11 10:31:23 +03:00
abc2cb f9f1d823cc Make Smart Filter editors distinct and preserve alpha at partial strength 2026-10-11 09:59:42 +03:00
abc2cb 135dc559a6 Merge upstream/main into Layers Panel feature (preserve new upstream changes)
Update to upstream 1e0f884; integrate UI/menu improvements and 17-language catalog updates while retaining Layers Panel functionality and screenshots outside the source branch.
2026-10-11 02:13:39 +03:00
abc2cb 6e0b3330c9 feat(ui): bring Photoshop-style Layers Panel options and scalable Smart Filters
Add a functional Layers flyout and Layers Panel Options dialog, configurable thumbnail sizes and bounds, mask/effect/copy preferences, and canvas reorder previews. Scale smart filter and layer effect rows with layer thumbnail size, and add regression coverage and translations.

Known limitation: Animation Options remains disabled because frame-property unification controls are not implemented.
2026-10-11 02:06:56 +03:00
Nigel Ihza Firdausy 8aebfd3ab3 Merge upstream main and port toolbar drag-release selection (#2301) 2026-10-11 05:26:33 +07:00
Nigel Ihza Firdausy f9795681b2 fix(toolbar): port drag-release flyout selection onto upstream toolbar 2026-10-11 05:26:15 +07:00
Nigel Ihza Firdausy b15b5a7587 feat(toolbar): drag from tool group into flyout to pick hidden tools (#2301) 2026-10-10 13:06:43 +07:00
mroxso 1b4faa90e6 Merge pull request #1 from storytold/main
Sync with upstream main
2026-10-09 11:56:58 +02:00
870 changed files with 56142 additions and 4930 deletions

No files matched your search

+19 -16
View File
@@ -37,7 +37,8 @@ concurrency:
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
@@ -78,7 +79,7 @@ jobs:
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
@@ -113,7 +114,7 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
@@ -122,7 +123,7 @@ jobs:
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
@@ -251,9 +252,9 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
save-if: false
@@ -306,8 +307,8 @@ jobs:
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: corpus
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -328,10 +329,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -351,17 +352,17 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: ci-web
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
curl --proto '=https' -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 builds, validates relative URLs, cuts the wasm into
@@ -419,15 +420,17 @@ jobs:
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
env:
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
- name: Build documentation
+3 -1
View File
@@ -32,5 +32,7 @@ jobs:
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
cargo-build-extra-args: "--features heif"
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui --exclude photocraft-gpu && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-gpu --locked -- --test-threads=1 && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
+2 -2
View File
@@ -44,9 +44,9 @@ jobs:
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: perf-release
+1
View File
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## 4. Picking work
+6 -1
View File
@@ -25,19 +25,23 @@ next to them.
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -116,6 +120,7 @@ next to the files:
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Generated
+37 -35
View File
@@ -4638,10 +4638,11 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"arboard",
"async-io",
"chrono",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4683,7 +4684,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4693,7 +4694,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4706,7 +4707,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4716,8 +4717,8 @@ dependencies = [
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-raw",
"rmcp",
"rustix 1.1.5",
"schemars",
@@ -4729,13 +4730,14 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
"photocraft-engine",
"photocraft-format",
"photocraft-io",
"photocraft-raw",
"serde_json",
"tokio",
"winresource",
@@ -4743,7 +4745,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"moxcms",
"rayon",
@@ -4751,7 +4753,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"exr",
"flate2",
@@ -4772,7 +4774,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4780,7 +4782,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4795,7 +4797,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4806,9 +4808,10 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"blake3",
"chrono",
"craft-print",
"exr",
"flate2",
@@ -4844,7 +4847,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4867,14 +4870,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"half",
"log",
@@ -4892,14 +4895,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"blake3",
"craft-docpath",
@@ -4929,18 +4932,18 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
version = "0.12.0"
version = "0.13.0"
[[package]]
name = "photocraft-ops"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4951,7 +4954,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4966,7 +4969,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4977,7 +4980,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4988,7 +4991,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"flate2",
"image",
@@ -5003,7 +5006,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -5015,18 +5018,15 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"memory-stats",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"serde_json",
]
[[package]]
name = "photocraft-text"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"flate2",
"parley",
@@ -5041,7 +5041,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5076,6 +5076,7 @@ dependencies = [
"serde",
"serde_json",
"sys-locale",
"tempfile",
"toml",
"unicode-bidi",
"winsafe",
@@ -5083,7 +5084,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5094,7 +5095,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"eframe",
"egui",
@@ -5116,7 +5117,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"flate2",
"js-sys",
@@ -8712,8 +8713,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"toml",
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,6 +59,8 @@ egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
+2 -2
View File
@@ -25,8 +25,8 @@ RUN set -eu; \
asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \
url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \
cd /tmp; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \
curl --proto '=https' -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \
printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \
tar -xzf "$asset" -C /usr/local/bin trunk; \
rm "$asset" "$asset.sha256"; \
+6 -2
View File
@@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
</p>
<h1 align="center">PhotoCraft</h1>
<p align="center">
@@ -217,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@@ -339,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
+6 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
@@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
+4
View File
@@ -31,6 +31,10 @@ photocraft-codecs = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+67 -9
View File
@@ -15,21 +15,27 @@ pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
An <out> that is the <in> file is refused, as the result would replace the original;
--in-place allows it.
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
--tiff-compression compresses it (none, the default; lzw; zip).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
only Gray and CMYK shrink) or progressive.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -73,20 +79,44 @@ struct Subcommand {
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
values: &[
"--new",
"--cmd",
"--params",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
values: &[
"--actions",
"--action",
"--in",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
@@ -226,6 +256,18 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--tiff-compression") {
o.encode.tiff_compression = match c {
"none" => photocraft_codecs::TiffCompression::None,
"lzw" => photocraft_codecs::TiffCompression::Lzw,
"zip" => photocraft_codecs::TiffCompression::Deflate,
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
};
}
if let Some(f) = a.get("--jpeg-format") {
o.encode.jpeg_format =
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
@@ -251,12 +293,28 @@ fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
.map_err(|error| error.to_string())
}
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
}
Ok(())
}
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
refuse_overwriting_input(a, input, output)?;
let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
@@ -563,6 +563,33 @@ fn tga_bits_choose_the_targa_depth() {
}
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
#[test]
fn jpeg_format_chooses_the_scans() {
let d = tmp("jpeg-format");
let a = d.join("a.png");
write_png(&a, 16, 8, 3);
let marker = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]);
for (format, progressive) in [(None, false), (Some("baseline"), false), (Some("optimized"), false), (Some("progressive"), true)] {
let out = d.join("a.jpg");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(f) = format {
convert.args(["--jpeg-format", f]);
}
ok(&mut convert);
assert_eq!(marker(&std::fs::read(&out).unwrap(), 0xC2), progressive, "--jpeg-format {format:?}");
}
for bad in ["Progressive", "1", ""] {
let out = d.join("bad.jpg");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
}
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
@@ -609,6 +636,42 @@ fn batch_refuses_to_write_over_its_inputs() {
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
#[test]
fn convert_refuses_to_write_over_its_input() {
let d = tmp("convert-in-place");
write_png(&d.join("in.png"), 8, 4, 5);
let before = std::fs::read(d.join("in.png")).unwrap();
let convert = |out: &Path, extra: &[&str]| bin().current_dir(&d).args(["convert", "in.png"]).arg(out).args(extra).output().unwrap();
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
outs.push(PathBuf::from("hard.png"));
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
outs.push(PathBuf::from("soft.png"));
}
for out in &outs {
for extra in [&[][..], &["--format", "png"][..], &["--quality", "80"][..]] {
let o = convert(out, extra);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "{} {extra:?}: {err}", out.display());
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "{} {extra:?} changed the original", out.display());
}
}
// Another file converts as before, leaving the original alone.
ok(bin().current_dir(&d).args(["convert", "in.png", "out.jpg"]));
assert!(d.join("out.jpg").is_file());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
@@ -796,3 +859,136 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut out, mut err) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = photocraft_cli::run(&args, &mut out, &mut err);
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
}
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
#[test]
fn top_level_help_version_and_no_arguments() {
let (code, out, err) = invoke(&[]);
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
for help in ["--help", "-h", "help"] {
let (code, out, err) = invoke(&[help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
}
for version in ["--version", "version"] {
let (code, out, err) = invoke(&[version]);
assert_eq!((code, err.as_str()), (0, ""), "{version}");
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
}
let (code, out, err) = invoke(&["frobnicate"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("frobnicate"), "{err}");
}
/// `<sub> --help` / `-h` prints help on stdout and returns without running the subcommand
/// (`mcp` and `serve` would otherwise block on stdin).
#[test]
fn every_subcommand_help_returns_on_stdout() {
for sub in SUBCOMMANDS {
for help in ["--help", "-h"] {
let (code, out, err) = invoke(&[sub, help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
}
}
}
/// Every subcommand refuses an unknown flag (bare or `=value`) with exit 2, naming it on stderr.
#[test]
fn every_subcommand_rejects_an_unknown_flag() {
for sub in SUBCOMMANDS {
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
let (code, out, err) = invoke(&args);
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
assert!(err.contains("--fromat"), "{args:?}: {err}");
}
}
}
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
#[test]
fn flag_value_forms_and_repeats() {
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
assert_eq!(code, 0, "{err}");
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
assert_eq!(eq, spaced);
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
// The last of a repeated value flag wins, whichever order they come in.
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
assert_eq!(code, 0, "{err}");
assert_eq!(out, eq);
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
assert_eq!(code, 0, "{err}");
assert!(out.is_empty(), "{out}");
// A bare flag takes no value; a value flag needs one.
let (code, out, err) = invoke(&["commands", "--json=1"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--json"), "{err}");
let (code, out, err) = invoke(&["commands", "--filter"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--filter"), "{err}");
}
/// Each `--params` applies to the `--cmd` before it; a `--cmd` without one gets `{}`; a
/// `--params` ahead of every `--cmd` fails naming `--params`.
#[test]
fn run_pairs_params_with_the_preceding_cmd() {
let (code, out, err) = invoke(&[
"run",
"--new",
r#"{"width":8,"height":8}"#,
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"First"}"#,
"--cmd",
"layer.new.layer",
"--cmd",
"layer.new.layer",
"--params",
r#"{"name":"Third"}"#,
"--cmd",
"document.inspect",
]);
assert_eq!(code, 0, "{err}");
let rows: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(rows.len(), 4, "{out}");
let names: Vec<&str> = rows[3]["result"]["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
let count = |n: &str| names.iter().filter(|x| **x == n).count();
assert_eq!((count("First"), count("Third")), (1, 1), "each --params applies to its own --cmd only: {names:?}");
// Three new layers plus the background; the middle one (no --params) has a default name.
assert_eq!(names.len(), 4, "{names:?}");
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
assert_ne!(code, 0);
assert!(err.contains("--params"), "{err}");
}
/// #7789: `convert` opens a developed raw at Raw Defaults › Resolution (default 300 ppi), as
/// `run`, `info` and the app do; it wrote 72.
#[test]
fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
let d = tmp("convert-raw");
let (w, h) = (24, 16);
let (raw, psd) = (d.join("IMG_0001.dng"), d.join("IMG_0001.psd"));
std::fs::write(&raw, DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)).build()).unwrap();
ok(bin().args(["convert"]).arg(&raw).arg(&psd));
let doc = photocraft_io::import("IMG_0001.psd", &std::fs::read(&psd).unwrap()).unwrap().document;
assert_eq!(doc.resolution_dpi, 300.0);
std::fs::remove_dir_all(d).unwrap();
}
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
mod orientation;
+12 -5
View File
@@ -17,11 +17,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "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", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
"afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -322,6 +323,12 @@ impl WebShell {
}
impl eframe::App for WebShell {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
// twin press closes the menu the first one opened (#2095).
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
@@ -384,8 +391,8 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
+4 -2
View File
@@ -33,6 +33,7 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11",
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -50,11 +51,12 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
# system alert sound for Beep When Done (services.rs, NSBeep). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
+20
View File
@@ -34,6 +34,13 @@ pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Value for [`egui::ViewportBuilder::with_icon`]: [`window_icon`] when [`sets_runtime_icon`]
/// is true; otherwise [`egui::IconData::default`] so eframe does not substitute its own icon
/// on the Dock after the window opens (#7253).
pub fn viewport_icon(exe: Option<&std::path::Path>) -> egui::IconData {
if sets_runtime_icon(exe) { window_icon() } else { egui::IconData::default() }
}
/// 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());
@@ -80,6 +87,19 @@ mod tests {
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
/// #7253: omitting `with_icon` made eframe paint its default glyph on the Dock once the window loaded.
#[test]
fn viewport_icon_blocks_eframe_default_when_runtime_icon_is_skipped() {
use std::path::Path;
let bundled = Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"));
if sets_runtime_icon(bundled) {
let icon = viewport_icon(bundled);
assert!(icon.width >= 256 && icon.width == icon.height);
} else {
assert_eq!(viewport_icon(bundled), egui::IconData::default());
}
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+22 -1
View File
@@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
return rx;
}
};
eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
@@ -47,6 +47,16 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
rx
}
/// The address `listener` is bound to: with `--control 0` the OS picks the port, and a client
/// needs that one, not the requested 0 (#6427). Falls back to the requested port if the OS
/// can't report it.
fn listening_addr(listener: &TcpListener, requested_port: u16) -> String {
match listener.local_addr() {
Ok(addr) => addr.to_string(),
Err(_) => format!("127.0.0.1:{requested_port}"),
}
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
@@ -131,6 +141,17 @@ mod tests {
use super::*;
use std::io::BufRead;
/// #6427: with port 0 the log names the port the OS bound, which a client can connect to.
#[test]
fn port_zero_logs_the_bound_port() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let bound = listener.local_addr().unwrap();
assert_ne!(bound.port(), 0);
let logged = listening_addr(&listener, 0);
assert_eq!(logged, format!("127.0.0.1:{}", bound.port()));
TcpStream::connect(&logged).unwrap();
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
+68 -25
View File
@@ -12,10 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
//! for one launch.
//! where it can be reset). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows
//! use DX12, and when DXGI lists an Intel adapter the instance leaves Vulkan out, so the first
//! start doesn't load the Intel Vulkan driver either. `WGPU_BACKEND` still overrides everything,
//! and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -35,6 +35,10 @@ use serde_json::{Value, json};
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
/// Qualcomm's vendor id as its Vulkan driver reports it.
pub const QUALCOMM: u32 = 0x5143;
/// Qualcomm's vendor id as DXGI (DX12) reports it: "QCOM".
pub const QUALCOMM_DXGI: u32 = 0x4D4F_4351;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
@@ -244,6 +248,18 @@ pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Com
}
}
/// The GPU makers whose Windows Vulkan driver `auto` avoids, so DX12 comes first: Intel's
/// (`igvk64.dll` crashed while the device was created, #4) and Qualcomm's Adreno driver on
/// Windows on ARM (it lost the device mid-session and the window went with it; DX12 on the same
/// machine didn't, #7537). The name is for System Info.
fn dx12_first_on_windows(vendor: u32) -> Option<&'static str> {
match vendor {
INTEL => Some("Intel"),
QUALCOMM | QUALCOMM_DXGI => Some("Qualcomm"),
_ => None,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
@@ -258,9 +274,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
/// Intel and Qualcomm on Windows ([`dx12_first_on_windows`]). For `cpu`, any GL adapter comes
/// before Vulkan, which is only the fallback for presenting the window (see [`backends`]). On
/// Windows GL comes after every other adapter, WARP included: GL there (VMware's SVGA3D, GL-only
/// old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
@@ -276,7 +293,7 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
_ => ty,
};
let be = match a.backend {
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
wgpu::Backend::Dx12 if os == Os::Windows && dx12_first_on_windows(a.vendor).is_some() => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
@@ -329,15 +346,18 @@ pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
/// the instance).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
/// The maker's name when picking `chosen` applied the Windows DX12 default of
/// [`dx12_first_on_windows`]: a Vulkan adapter of the same maker was available, or, for Intel,
/// `vulkan_left_out` ([`windows_auto_backends`] left Vulkan out of the instance).
pub fn dx12_default_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> Option<&'static str> {
let c = adapters.get(chosen)?;
if os != Os::Windows || c.backend != wgpu::Backend::Dx12 {
return None;
}
let maker = dx12_first_on_windows(c.vendor)?;
let applied =
(vulkan_left_out && c.vendor == INTEL) || adapters.iter().any(|a| a.backend == wgpu::Backend::Vulkan && dx12_first_on_windows(a.vendor) == Some(maker));
applied.then_some(maker)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -349,8 +369,8 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
/// selection (Intel and Qualcomm on Windows, software adapters for `cpu`) and the marker update
/// once the adapter is chosen. `note` receives a remark for System Info (e.g. the DX12 default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
@@ -396,9 +416,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
if backend == GpuBackend::Auto
&& let Some(maker) = dx12_default_applied(&cands, i, os, vulkan_left_out)
{
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
Some(format!("{maker} graphics on Windows: using DirectX 12 (the {maker} Vulkan driver is known to crash)"));
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
@@ -772,13 +794,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
assert_eq!(dx12_default_applied(&intel, i, Os::Windows, false), Some("Intel"));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
assert_eq!(dx12_default_applied(&nv, i, Os::Windows, false), None);
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -789,6 +811,27 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #7537: on Windows on ARM the Adreno Vulkan driver lost the device mid-session and took the
/// window with it; DX12 on the same machine didn't. DXGI and Vulkan report different vendor ids.
#[test]
fn qualcomm_on_windows_defaults_to_dx12() {
use wgpu::{Backend as B, DeviceType as T};
let adreno = [cand(QUALCOMM, T::IntegratedGpu, B::Vulkan), cand(QUALCOMM_DXGI, T::IntegratedGpu, B::Dx12)];
let i = pick(&adreno, Auto, Os::Windows).unwrap();
assert_eq!(adreno[i].backend, B::Dx12);
assert_eq!(dx12_default_applied(&adreno, i, Os::Windows, false), Some("Qualcomm"));
// Only Vulkan offered: still Vulkan, and no note.
assert_eq!(pick(&adreno[..1], Auto, Os::Windows), Some(0));
assert_eq!(dx12_default_applied(&adreno, 0, Os::Windows, false), None);
// An Intel Vulkan adapter doesn't make a Qualcomm DX12 pick "the default".
let mixed = [cand(INTEL, T::IntegratedGpu, B::Vulkan), adreno[1]];
assert_eq!(dx12_default_applied(&mixed, 1, Os::Windows, false), None);
// Vulkan left out of the instance (an Intel adapter was listed) speaks only for Intel.
assert_eq!(dx12_default_applied(&adreno[1..], 0, Os::Windows, true), None);
// Elsewhere Vulkan stays first.
assert_eq!(adreno[pick(&adreno, Auto, Os::Other).unwrap()].backend, B::Vulkan);
}
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
/// Windows creates the instance without Vulkan.
@@ -824,8 +867,8 @@ mod tests {
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&listed, Auto, Os::Windows).unwrap();
assert_eq!(i, 0);
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel"));
assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None);
}
#[test]
+40 -17
View File
@@ -91,27 +91,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -416,6 +425,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+14 -5
View File
@@ -102,9 +102,7 @@ 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 };
let viewport = egui::ViewportBuilder::default().with_icon(app_icon::viewport_icon(std::env::current_exe().ok().as_deref()));
eframe::NativeOptions {
viewport: viewport
.with_app_id(APP_ID)
@@ -526,6 +524,13 @@ fn main() -> eframe::Result {
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
// keeps its own process instead of handing its files over and being ignored.
// Folded in here, inside the block that only non-macOS builds compile, so the binding
// above keeps its `mut` off macOS, where nothing is folded in.
let mut launch_options = launch_options;
launch_options.extend_from_slice(&single_instance::env_launch_options());
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
@@ -786,6 +791,9 @@ fn main() -> eframe::Result {
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
app.perf.gpu_info.set_available(&rs.available_adapters);
// Every egui texture uploads through this adapter, whichever canvas draws:
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
// Keep an earlier note, such as a requested adapter that couldn't be used.
@@ -803,8 +811,9 @@ fn main() -> eframe::Result {
{
app.set_wgpu(rs);
} else {
// The window still draws with wgpu: record its errors instead of panicking.
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
// The window still draws with wgpu: record its errors instead of panicking,
// and notice when the device is lost (#3438).
app.watch_window_device(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
+16 -4
View File
@@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option<PathBuf> {
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Which file type's extensions belong on the name the dialog returned: the one it reported, or,
/// when the dialog offered only one type, that one. It reports no pick then, and the name it was
/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as
/// `Swatches.aco`. `None` when the answer names no type this dialog offered.
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
match picked {
Some(i) if i < filters.len() => Some(i),
None if filters.len() == 1 => Some(0),
_ => None,
}
}
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
/// picked. When that changes the name to one that already exists, the dialog opens again under
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
@@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
// With one type offered there is nothing to pick, and a typed extension is the user's.
let exts: &[String] = match picked {
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
_ => &[],
// A typed extension is the user's own: `with_chosen_type` keeps one it recognises.
let exts: &[String] = match chosen_type(&req.filters, picked) {
Some(i) => &req.filters[i].1,
None => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
+30
View File
@@ -235,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() {
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and
/// that one type gives it its extension instead of a file named `Swatches` being written.
#[test]
fn a_single_file_type_gives_the_bare_name_its_extension() {
let typed: &'static str = "file:///tmp/photocraft-none/Swatches";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco")));
daemon.kill().ok();
}
/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`,
/// so the rule itself is checked here too, which needs none.
#[test]
fn the_type_that_gives_the_extension_is_the_one_picked() {
let one: Vec<Filter> = vec![("Color Swatches".into(), vec!["aco".into()])];
// One type offered: the dialog reports no pick, and it was given the stem.
assert_eq!(chosen_type(&one, None), Some(0));
assert_eq!(chosen_type(&one, Some(0)), Some(0));
let many: Vec<Filter> = request("test").filters;
assert_eq!(chosen_type(&many, Some(1)), Some(1));
// Several types and no pick: nothing to add, as before.
assert_eq!(chosen_type(&many, None), None);
// An answer naming a type this dialog never offered is not a pick either.
assert_eq!(chosen_type(&many, Some(99)), None);
}
+197 -87
View File
@@ -1,10 +1,8 @@
//! Native platform services: file dialogs (rfd), filesystem, codecs.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::{Cell, RefCell};
use std::future::Future;
@@ -17,12 +15,13 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "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", "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",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -67,10 +66,12 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
("DPX and Cineon", &["dpx", "cin"]),
("Amiga IFF", &["iff", "lbm", "ilbm"]),
("DICOM", &["dcm", "dicom", "dc3"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Swatches", &["aco", "ase"]),
("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -102,11 +103,12 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
("Cineon", &["cin"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])];
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
@@ -198,10 +200,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
};
if multiple {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
} else {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
}
}
FileDialogRequest::Folder { initial_dir } => {
@@ -209,7 +211,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
}
FileDialogRequest::Save { suggested } => {
let filters = save_filters(&suggested);
@@ -244,10 +246,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, req));
match portal {
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
}
}
})
@@ -256,7 +258,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
{
let _ = filters;
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
}
}
};
@@ -270,8 +272,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
}
}
fn path_of(file: &rfd::FileHandle) -> String {
file.path().to_string_lossy().into_owned()
/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
/// are to be written to.
fn answer<'a>(paths: impl IntoIterator<Item = &'a Path>, saving: bool, make: impl FnOnce(Vec<String>) -> FileDialogAnswer) -> FileDialogAnswer {
match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
Ok(paths) => make(paths),
Err(e) => FileDialogAnswer::Unavailable(e),
}
}
/// [`answer`] for a dialog that picks one path.
fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
match photocraft_ui_egui::file_open::path_text(path, saving) {
Ok(path) => make(path),
Err(e) => FileDialogAnswer::Unavailable(e),
}
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
@@ -330,6 +346,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
fn same_file(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(not(unix))]
{
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
}
/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file
/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a
/// file system that ignores letter case is the same entry; a hard link is another entry, which a
/// save replaces while the first name keeps the old file. False when the folder can't be read.
fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool {
let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf();
if !same_file(a, b) || !same_file(&folder(a), &folder(b)) {
return false;
}
let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false };
if x == y {
return true;
}
let Ok(entries) = std::fs::read_dir(folder(a)) else { return false };
let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect();
!(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y))
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
settings.io_options()
}
@@ -387,12 +441,13 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
fn recovery_services(dir: Option<PathBuf>) -> Services {
let recover_dir = dir.clone();
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s4) = (store.clone(), store.clone(), store.clone());
let (s1, s2, s4, s5) = (store.clone(), store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
})),
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
flush_autosaves: Some(Box::new(move || with_store(&s5, RecoveryStore::flush).unwrap_or_default())),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
@@ -415,6 +470,17 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
}
}
/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a
/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the
/// app keeps its documents.
fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn {
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
}
fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn {
Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px)))
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
@@ -527,9 +593,9 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}) as photocraft_ui_egui::AutomationCommandFn
});
Services {
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
crate::crash_guard::guard("Open", || {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
@@ -544,6 +610,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))),
automation_read,
automation_write,
automation_open_pcraft,
@@ -559,7 +626,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
beep: Some(platform_beep),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
@@ -571,7 +638,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
clipboard_get_image: Some({
let clip = clip.clone();
let unreadable = clip_unreadable.clone();
Box::new(move || {
guarded_clipboard_get(move || {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
@@ -635,77 +702,19 @@ fn platform_beep() {
{
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
}
#[cfg(not(target_os = "windows"))]
// The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal
// bell below is silent for an app started from the Finder or the Dock.
#[cfg(target_os = "macos")]
objc2_app_kit::NSBeep();
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
// No beep API among this app's other platforms' dependencies: write the terminal bell
// (audible when launched from a terminal, which is the same cue macOS terminals use).
// No beep API among the other platforms' dependencies: write the terminal bell (audible
// when launched from a terminal).
use std::io::Write as _;
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
}
}
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
#[allow(dead_code)]
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?;
let (w, h) = (img.width(), img.height());
let depth = match img.sample_type() {
CS::U8 => SampleType::U8,
CS::U16 => SampleType::U16,
_ => SampleType::F32,
};
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
let mode = if gray {
ColorMode::Grayscale
} else if cmyk {
ColorMode::Cmyk
} else {
ColorMode::Rgb
};
let target = match mode {
ColorMode::Grayscale => ChannelLayout::GrayA,
ColorMode::Cmyk => ChannelLayout::CmykA,
_ => ChannelLayout::Rgba,
};
let sample = match depth {
SampleType::U8 => CS::U8,
SampleType::U16 => CS::U16,
SampleType::F32 => CS::F32,
};
let conv = img.convert(target, sample);
let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string());
let mut doc = Document::new(stem, Size::new(w, h), mode, depth);
doc.icc_profile = img.icc.clone().map(std::sync::Arc::new);
if let Some((x, _)) = img.meta.dpi {
doc.resolution_dpi = x;
}
let mut layer = Layer::raster("Background", doc.pixel_format());
let data = conv.to_normalized();
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
doc.layers.push(layer);
Ok(doc)
}
#[allow(dead_code)]
pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?;
let buf = photocraft_compose::flatten(doc);
let (w, h) = (buf.rect.width(), buf.rect.height());
let data: Vec<f32> = buf.px.iter().flat_map(|p| *p).collect();
let img = match doc.depth {
SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels),
SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::<Vec<_>>()),
SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data),
}
.map_err(|e| e.to_string())?;
let img = match &doc.icc_profile {
Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())),
_ => img,
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -714,6 +723,31 @@ mod tests {
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
/// (Open would fail with "No such file", Save would write a second, renamed file).
#[test]
fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() {
#[cfg(unix)]
let bad = {
use std::os::unix::ffi::OsStrExt;
std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png"))
};
#[cfg(windows)]
let bad = {
use std::os::windows::ffi::OsStringExt;
let mut wide: Vec<u16> = "img".encode_utf16().collect();
wide.push(0xD800);
wide.extend(".png".encode_utf16());
std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide))
};
let good = std::env::temp_dir().join("ok.png");
let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo);
assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name")));
assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap()));
let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths);
assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again")));
}
/// #6121: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
@@ -831,6 +865,9 @@ mod tests {
assert!(!globs.iter().any(|glob| matches(glob, "txt")));
let svg_filter = open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(svg_filter, open_filter_extensions(SVG_EXTS));
let psd_filter = open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts).unwrap_or_default();
assert_eq!(psd_filter, open_filter_extensions(&["psd", "psb", "psdt"]));
assert!(psd_filter.iter().any(|glob| matches(glob, "psd")) && psd_filter.iter().any(|glob| matches(glob, "PSD")));
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
@@ -860,6 +897,10 @@ mod tests {
assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS);
assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]);
assert_eq!(open_filters().into_iter().find(|(name, _)| *name == "SVG").map(|(_, exts)| exts), Some(vec!["svg".to_string(), "svgz".to_string()]));
assert_eq!(
open_filters().into_iter().find(|(name, _)| *name == "Photoshop").map(|(_, exts)| exts),
Some(vec!["psd".to_string(), "psb".to_string(), "psdt".to_string()])
);
}
#[test]
@@ -1013,6 +1054,50 @@ mod tests {
assert_eq!(dialog_file_name("", true), None);
}
/// A name in another letter case reaches the same file where the file system ignores case
/// (Windows, and macOS by default), and only there. Two files with the same contents are two.
#[test]
fn same_file_follows_the_file_system() {
let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD"));
std::fs::write(&cat, b"same").unwrap();
std::fs::write(&dog, b"same").unwrap();
assert!(same_file(&cat, &cat));
assert!(!same_file(&cat, &dog));
assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored");
assert!(!same_file(&dir.join("missing.psd"), &cat));
std::fs::remove_dir_all(&dir).unwrap();
}
/// A hard link is another entry in the folder, though it reaches the same file: a save to it
/// replaces that entry and leaves the other name on the old file. Another spelling of a name,
/// where letter case is ignored, is the same entry.
#[test]
fn a_hard_link_is_another_entry() {
let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id()));
std::fs::create_dir_all(dir.join("other")).unwrap();
let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd"));
std::fs::write(&cat, b"cat").unwrap();
std::fs::hard_link(&cat, &bare).unwrap();
std::fs::hard_link(&cat, &other).unwrap();
// Where both names reach one file (one inode on Unix), the folder still lists both.
let one_file = |_: &Path, _: &Path| true;
assert!(!same_entry(&bare, &cat, one_file));
assert!(!same_entry(&bare, &cat, same_file));
assert!(!same_entry(&other, &cat, same_file), "another folder");
assert!(same_entry(&cat, &cat, one_file));
assert!(same_entry(&cat, &cat, same_file));
if upper.exists() {
assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored");
} else {
std::fs::hard_link(&cat, &upper).unwrap();
assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart");
}
assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
@@ -1290,4 +1375,29 @@ mod tests {
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
/// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is
/// the paste's error in the status bar, not a crash: the document stays and the next paste
/// works. The same for a panic while writing it.
#[test]
fn a_panicking_clipboard_read_is_reported_not_a_crash() {
let panics = Rc::new(Cell::new(true));
let p = panics.clone();
let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) });
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() });
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
let layers = app.session.active().unwrap().doc.layers.len();
let error = app.run("edit.paste", json!({})).unwrap_err();
assert!(error.contains("Paste failed"), "{error}");
assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status);
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged");
panics.set(false);
app.run("edit.paste", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works");
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
}
}
@@ -1,5 +1,6 @@
use super::*;
use photocraft_color::Color;
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::Size;
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
use std::sync::atomic::{AtomicUsize, Ordering};
+408 -35
View File
@@ -3,8 +3,9 @@
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! drops it when the process ends, crash included), listens on a loopback port (inside a Flatpak
//! sandbox, on a Unix socket: see [`transport`]) and writes where it listens plus a fresh 256-bit
//! token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
@@ -26,7 +27,11 @@
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
@@ -38,6 +43,9 @@ use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
#[cfg(unix)]
const SOCKET_FILE: &str = "instance.sock";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
@@ -59,6 +67,45 @@ pub struct Timing {
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// How the primary listens for later launches.
#[derive(Clone, Debug, PartialEq)]
pub enum Transport {
/// A loopback port (the default): private to the machine, reachable by every process on it.
Loopback,
/// A Unix socket at this path. Inside a Flatpak sandbox a loopback port is not enough: each
/// `flatpak run` has its own network namespace, so the second sandbox's `127.0.0.1` never
/// reaches the first (#1540). `$XDG_RUNTIME_DIR/app/<app id>/` is the same directory in every
/// sandbox of the app, and the user's alone (0700).
#[cfg(unix)]
Socket(PathBuf),
}
/// The transport for this process: a Unix socket when running inside a Flatpak (`FLATPAK_ID` is
/// set and `XDG_RUNTIME_DIR` names the runtime directory), else a loopback port.
pub fn transport() -> Transport {
#[cfg(unix)]
if let Some(path) = sandbox_socket(std::env::var_os("FLATPAK_ID").as_deref(), std::env::var_os("XDG_RUNTIME_DIR").as_deref()) {
return Transport::Socket(path);
}
Transport::Loopback
}
/// `$XDG_RUNTIME_DIR/app/<FLATPAK_ID>/instance.sock`, when both are set.
#[cfg(unix)]
fn sandbox_socket(flatpak_id: Option<&std::ffi::OsStr>, runtime_dir: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let id = flatpak_id.filter(|v| !v.is_empty())?;
let dir = runtime_dir.filter(|v| !v.is_empty())?;
Some(Path::new(dir).join("app").join(id).join(SOCKET_FILE))
}
/// Where the running primary listens, as `instance.json` records it.
#[derive(Clone, Debug, PartialEq)]
enum Endpoint {
Port(u16),
#[cfg(unix)]
Socket(PathBuf),
}
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
@@ -82,10 +129,71 @@ pub struct LaunchContext<'a> {
pub control: bool,
/// Paths that aren't valid Unicode: this launch reports them.
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
/// ones this launch's environment sets (see [`env_launch_options`]).
pub launch_options: &'a [&'static str],
}
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
/// process alone.
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
];
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
/// variable is read where it takes effect, so a value ignored there keeps no process here either.
type ValueRule = fn(&str) -> bool;
/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and
/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE`
/// changes nothing there and so needs no process of its own here.
fn present_mode_is_on(value: &str) -> bool {
crate::presentation::parse(Some(value), None).0.mode.is_some()
}
/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`.
fn frame_latency_is_on(value: &str) -> bool {
crate::presentation::parse(None, Some(value)).0.latency.is_some()
}
/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the
/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here.
fn stroke_trace_is_on(value: &str) -> bool {
!matches!(value.trim(), "" | "0" | "off" | "false")
}
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
fn flag_is_on(value: &str) -> bool {
!value.is_empty() && value != "0"
}
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
fn path_is_set(value: &str) -> bool {
!value.is_empty()
}
/// The per-process options this launch's environment sets, named as the flags they stand for. A
/// launch that sets only variables would otherwise hand its files to the running instance, which
/// never sees the override, and exit.
pub fn env_launch_options() -> Vec<&'static str> {
env_launch_options_from(|name| std::env::var_os(name))
}
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
if !c.per_user_settings {
@@ -110,6 +218,10 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
/// The socket file to remove on the first shutdown (a Unix socket transport only); taken
/// then, so the second call (`Drop`) can't remove the socket the next primary has bound.
#[cfg(unix)]
socket: Mutex<Option<PathBuf>>,
}
struct Shared {
@@ -151,6 +263,10 @@ impl Primary {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
#[cfg(unix)]
if let Some(path) = self.socket.lock().unwrap_or_else(PoisonError::into_inner).take() {
let _ = std::fs::remove_file(path);
}
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
@@ -211,9 +327,15 @@ pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
/// [`claim`] with explicit timeouts, over the transport this process would use ([`transport`]).
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
claim_in(dir, paths, timing, &transport())
}
/// [`claim`] over `transport`. Until `timing.handoff` runs out, each round tries the lock (free
/// once a quitting or crashed primary is gone) and then the running primary, wherever
/// `instance.json` says it listens.
pub fn claim_in(dir: &Path, paths: &[String], timing: Timing, transport: &Transport) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
@@ -227,7 +349,7 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing) {
return match serve(dir, lock, timing, transport) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
@@ -235,8 +357,8 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
if let Some((endpoint, token)) = read_info(dir) {
match send(&endpoint, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
@@ -248,11 +370,15 @@ pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
}
}
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
fn serve(dir: &Path, lock: File, timing: Timing, transport: &Transport) -> Result<Primary, String> {
let (listener, endpoint) = Listener::bind(transport)?;
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, port, &token)?;
write_info(dir, &endpoint, &token)?;
#[cfg(unix)]
let socket = match &endpoint {
Endpoint::Socket(path) => Some(path.clone()),
Endpoint::Port(_) => None,
};
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
@@ -260,7 +386,7 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
Ok(stream) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
@@ -288,11 +414,117 @@ fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
Ok(Primary {
lock: Mutex::new(Some(lock)),
shared,
#[cfg(unix)]
socket: Mutex::new(socket),
})
}
/// The primary's listener on either transport.
enum Listener {
Tcp(TcpListener),
#[cfg(unix)]
Unix(UnixListener),
}
impl Listener {
fn bind(transport: &Transport) -> Result<(Listener, Endpoint), String> {
match transport {
Transport::Loopback => {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
Ok((Listener::Tcp(listener), Endpoint::Port(port)))
}
#[cfg(unix)]
Transport::Socket(path) => {
let at = |e: std::io::Error| format!("{}: {e}", path.display());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(at)?;
}
// This process holds the lock, so a file already there is a crashed primary's.
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path).map_err(at)?;
// The runtime directory is the user's alone already; keep the socket so too.
std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)).map_err(at)?;
Ok((Listener::Unix(listener), Endpoint::Socket(path.clone())))
}
}
}
fn accept(&self) -> std::io::Result<Stream> {
match self {
Listener::Tcp(l) => l.accept().map(|(s, _)| Stream::Tcp(s)),
#[cfg(unix)]
Listener::Unix(l) => l.accept().map(|(s, _)| Stream::Unix(s)),
}
}
}
/// One connection, on either transport.
enum Stream {
Tcp(TcpStream),
#[cfg(unix)]
Unix(UnixStream),
}
impl Stream {
/// Connect to the primary; `timeout` bounds the loopback connect (a Unix connect is immediate).
fn connect(endpoint: &Endpoint, timeout: Duration) -> std::io::Result<Stream> {
match endpoint {
Endpoint::Port(port) => TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, *port)), timeout).map(Stream::Tcp),
#[cfg(unix)]
Endpoint::Socket(path) => UnixStream::connect(path).map(Stream::Unix),
}
}
fn set_read_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_read_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_read_timeout(dur),
}
}
fn set_write_timeout(&self, dur: Option<Duration>) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.set_write_timeout(dur),
#[cfg(unix)]
Stream::Unix(s) => s.set_write_timeout(dur),
}
}
/// One read through a shared reference (both socket types read through `&self`).
fn read_once(&self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => (&*s).read(buf),
#[cfg(unix)]
Stream::Unix(s) => (&*s).read(buf),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Tcp(s) => s.write(buf),
#[cfg(unix)]
Stream::Unix(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Tcp(s) => s.flush(),
#[cfg(unix)]
Stream::Unix(s) => s.flush(),
}
}
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
fn serve_client(stream: Stream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
@@ -308,7 +540,7 @@ fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing)
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a TcpStream,
stream: &'a Stream,
until: Instant,
}
@@ -317,25 +549,30 @@ impl Read for Deadline<'_> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
self.stream.read_once(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
fn read_request(stream: &Stream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// Endpoint and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
fn write_info(dir: &Path, endpoint: &Endpoint, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
let mut body = json!({"token": token, "pid": std::process::id()});
match endpoint {
Endpoint::Port(port) => body["port"] = json!(port),
#[cfg(unix)]
Endpoint::Socket(socket) => body["socket"] = json!(socket.to_string_lossy()),
}
let body = body.to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
@@ -349,10 +586,15 @@ fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
})
}
fn read_info(dir: &Path) -> Option<(u16, String)> {
fn read_info(dir: &Path) -> Option<(Endpoint, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let token = v.get("token")?.as_str()?.to_string();
#[cfg(unix)]
if let Some(socket) = v.get("socket").and_then(Value::as_str) {
return Some((Endpoint::Socket(PathBuf::from(socket)), token));
}
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((port, v.get("token")?.as_str()?.to_string()))
Some((Endpoint::Port(port), token))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
@@ -370,11 +612,10 @@ fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
Some((paths, wait))
}
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
/// Offer `paths` to the primary at `endpoint`; `Ok` once its UI has taken them.
fn send(endpoint: &Endpoint, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
let mut stream = Stream::connect(endpoint, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
@@ -435,6 +676,21 @@ mod tests {
}
}
/// A socket path short enough for every Unix (`sun_path` is about 100 bytes; macOS: 104).
#[cfg(unix)]
fn short_socket_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pc-si-{}-{tag}", std::process::id())).join(SOCKET_FILE)
}
/// The loopback port the primary under `dir` listens on.
fn port_of(dir: &Path) -> u16 {
match read_info(dir).unwrap().0 {
Endpoint::Port(port) => port,
#[cfg(unix)]
Endpoint::Socket(path) => panic!("expected a loopback port, found {}", path.display()),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
@@ -496,7 +752,7 @@ mod tests {
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
assert!(read_info(&dir).is_some_and(|(endpoint, _)| endpoint != Endpoint::Port(1)));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
@@ -516,10 +772,10 @@ mod tests {
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (port, token) = read_info(&dir).unwrap();
let (endpoint, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(port, &token, &["b.psd".into()], short).is_err());
assert!(send(&endpoint, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -557,7 +813,7 @@ mod tests {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let (port, _) = read_info(&dir).unwrap();
let port = port_of(&dir);
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
@@ -575,7 +831,7 @@ mod tests {
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let (port, _) = read_info(&dir).unwrap();
let port = port_of(&dir);
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
@@ -602,7 +858,7 @@ mod tests {
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
write_info(&dir, &Endpoint::Port(1), &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
@@ -616,7 +872,7 @@ mod tests {
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
write_info(&dir, &Endpoint::Port(closed), &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
@@ -645,4 +901,121 @@ mod tests {
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
/// directory, which every sandbox of the app shares, with the same lock, token and reply.
#[cfg(unix)]
#[test]
fn a_sandboxed_second_launch_hands_its_paths_over_a_unix_socket() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("socket");
// Short: a Unix socket path is limited to about 100 bytes (macOS: 104).
let socket = short_socket_path("a");
let transport = Transport::Socket(socket.clone());
let primary = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
};
assert_eq!(std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, 0o600);
let info: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).unwrap()).unwrap();
assert_eq!(info.get("socket").and_then(Value::as_str), Some(socket.to_str().unwrap()));
assert!(info.get("port").is_none());
let mut ui = primary.connect(&egui::Context::default());
let paths = ["/home/a/one.psd".to_string()];
let (d, p, t) = (dir.clone(), paths.clone(), transport.clone());
let second = std::thread::spawn(move || claim_in(&d, &p, TIMING, &t));
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.to_vec())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// The token still gates the socket.
let (endpoint, _) = read_info(&dir).unwrap();
assert!(send(&endpoint, "not the token", &["x.psd".into()], Instant::now() + FAST.handoff).is_err());
primary.shutdown();
assert!(!socket.exists(), "shutdown removes the socket file");
// The next primary binds the same path; the old one's drop (a second shutdown) must leave it.
let next = match claim_in(&dir, &[], FAST, &transport) {
Launch::Primary(p) => p,
_ => panic!("the lock is free again"),
};
drop(primary);
assert!(socket.exists(), "a second shutdown doesn't remove the next primary's socket");
drop(next);
let _ = std::fs::remove_dir_all(&dir);
}
/// A socket file left by a crashed primary (and a stale `instance.json` naming it) is replaced.
#[cfg(unix)]
#[test]
fn a_crashed_primary_s_socket_file_does_not_block_the_next_one() {
use std::os::unix::fs::FileTypeExt;
let dir = temp_dir("stale-socket");
let socket = short_socket_path("b");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(socket.parent().unwrap()).unwrap();
std::fs::write(&socket, b"not a socket").unwrap();
write_info(&dir, &Endpoint::Socket(socket.clone()), &generate_token().unwrap()).unwrap();
let primary = match claim_in(&dir, &["a.psd".into()], FAST, &Transport::Socket(socket.clone())) {
Launch::Primary(p) => p,
Launch::Forwarded => panic!("nothing was listening"),
Launch::Standalone(e) => panic!("{e}"),
};
assert!(std::fs::metadata(&socket).unwrap().file_type().is_socket());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(socket.parent().unwrap());
}
/// The socket transport is chosen only inside a Flatpak with a runtime directory.
#[cfg(unix)]
#[test]
fn the_socket_transport_needs_a_flatpak_id_and_a_runtime_directory() {
use std::ffi::OsStr;
let (id, run) = (OsStr::new("ai.storyteller.photocraft"), OsStr::new("/run/user/1000"));
assert_eq!(sandbox_socket(Some(id), Some(run)), Some(PathBuf::from("/run/user/1000/app/ai.storyteller.photocraft/instance.sock")));
assert_eq!(sandbox_socket(None, Some(run)), None);
assert_eq!(sandbox_socket(Some(id), None), None);
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
}
/// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch
/// that sets only variables has per-process options, so it keeps its own process rather than
/// handing its files to the running instance, which never sees the override.
#[test]
fn an_environment_override_counts_as_a_process_option() {
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
assert_eq!(
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
vec!["--stroke-trace", "--automation-write-root"]
);
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
// it is read where it takes effect, not by one rule shared across all six.
assert!(with(&[]).is_empty());
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
// is ignored there, so neither keeps a process of its own here.
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
// Spaces are part of the value only where the reader keeps them: the trace and the two
// presentation values are trimmed where they take effect, the menu flag and a path are not.
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
assert_eq!(
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
vec!["--in-window-menus", "--automation-write-root"]
);
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
new_instance_env: None,
control: false,
unreadable_paths: false,
launch_options: &[],
};
let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base });
assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}");
}
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod command_line_usage;
mod control_exit_code;
mod startup_without_display;
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 20h18" />
<rect x="5" y="4" width="5" height="12" rx="1" />
<rect x="14" y="9" width="5" height="7" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M12 3v18" />
<rect x="5" y="5" width="14" height="5" rx="1" />
<rect x="8" y="14" width="8" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3v18" />
<rect x="8" y="5" width="12" height="5" rx="1" />
<rect x="8" y="14" width="7" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M20 3v18" />
<rect x="4" y="5" width="12" height="5" rx="1" />
<rect x="9" y="14" width="7" height="5" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4h18" />
<rect x="5" y="8" width="5" height="12" rx="1" />
<rect x="14" y="8" width="5" height="7" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 12h18" />
<rect x="5" y="5" width="5" height="14" rx="1" />
<rect x="14" y="8" width="5" height="8" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 416 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 7h16" />
<rect x="7" y="2" width="10" height="3" rx="1" />
<path d="M4 14h16" />
<rect x="7" y="9" width="10" height="3" rx="1" />
<path d="M4 21h16" />
<rect x="7" y="16" width="10" height="3" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

+22
View File
@@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="2" y="6" width="4" height="12" rx="1" />
<path d="M4 2v2" />
<path d="M4 20v2" />
<rect x="10" y="6" width="4" height="12" rx="1" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<rect x="18" y="6" width="4" height="12" rx="1" />
<path d="M20 2v2" />
<path d="M20 20v2" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<path d="M21 4v16" />
<rect x="9" y="7" width="6" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M3 4v16" />
<rect x="5" y="7" width="3" height="10" rx="1" />
<path d="M10 4v16" />
<rect x="12" y="7" width="3" height="10" rx="1" />
<path d="M17 4v16" />
<rect x="19" y="7" width="3" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 517 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M7 4v16" />
<rect x="2" y="7" width="3" height="10" rx="1" />
<path d="M14 4v16" />
<rect x="9" y="7" width="3" height="10" rx="1" />
<path d="M21 4v16" />
<rect x="16" y="7" width="3" height="10" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 516 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<rect x="7" y="5" width="10" height="3" rx="1" />
<path d="M4 10h16" />
<rect x="7" y="12" width="10" height="3" rx="1" />
<path d="M4 17h16" />
<rect x="7" y="19" width="10" height="3" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 517 B

+22
View File
@@ -0,0 +1,22 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<rect x="6" y="2" width="12" height="4" rx="1" />
<path d="M2 4h2" />
<path d="M20 4h2" />
<rect x="6" y="10" width="12" height="4" rx="1" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<rect x="6" y="18" width="12" height="4" rx="1" />
<path d="M2 20h2" />
<path d="M20 20h2" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
<path d="M4 3h16" />
<path d="M4 21h16" />
<rect x="7" y="9" width="10" height="6" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 387 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Slice Select tool glyph (not from an icon set): the Slice knife with a pointer. -->
<path d="M10 14l4-4" />
<path d="M14 10l5.6-5.6a2 2 0 0 1 2.8 2.8L16.5 12.5z" />
<path d="M4 12v9l2.5-2.5 2 3.5 1.7-1-2-3.5H12z" />
</svg>

After

Width:  |  Height:  |  Size: 454 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
<path d="M6 5V3h12v2" />
<path d="M12 3v11" />
<path d="M9.5 14h5" />
<path d="M3 17c5 5 13 5 18 0" />
</svg>

After

Width:  |  Height:  |  Size: 419 B

+3
View File
@@ -8,8 +8,11 @@ tiles/<blake3>.zst
blobs/<blake3>.zst
thumb.png
composite/preview.png
save.lock
```
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
## Implemented load limits
+1
View File
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered.
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
+60
View File
@@ -17,6 +17,13 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.brianblakely]
sync_github_name = true
[people.abdul-karim-mia]
real_name = "Abdul karim mia"
sync_github_name = false
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
@@ -116,6 +123,9 @@ sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.zi-ger]
sync_github_name = true
[people.Losminek]
sync_github_name = true
@@ -166,6 +176,9 @@ sync_github_name = true
real_name = "Sidney Markowitz"
sync_github_name = true
[people.theoabw]
real_name = "Theo Wilenius"
[people.TaylorBurke]
sync_github_name = true
@@ -191,3 +204,50 @@ sync_github_name = true
[people.ha1des]
sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.AndreeSalazar]
real_name = "Andreé Salazar"
sync_github_name = true
[people.LorenzoFasolino]
real_name = "Lorenzo Fasolino"
display_name = "Lorenzo Fasolino"
sync_github_name = false
[people.zolyy]
real_name = "Gall Zoltan"
sync_github_name = false
[people.theshubh77]
real_name = "Shubham Bhamare"
sync_github_name = true
[people.ksaxena149]
sync_github_name = true
[people.giang17]
real_name = "Giang Nguyen"
sync_github_name = true
[people.aqibmohd271]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.Gotix-eg]
real_name = "Ahmed Tammam"
display_name = "Gotix"
sync_github_name = true
[people.adi-1108]
real_name = "Aditya Vishwakarma"
sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
+3 -3
View File
@@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs`
parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
+9
View File
@@ -20,6 +20,15 @@ impl Stream {
self.class(id) == Some(Tag::of(class))
}
/// The raw slot count of an object's array field (including null slots), for charging
/// traversal work before nulls are filtered out. `None` when the field is not an array.
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
match self.field(id, tag)? {
Value::Array(a) => Some(a.len()),
_ => None,
}
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
+10 -1
View File
@@ -210,7 +210,16 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
// Index each record's maximum radius once. Scanning the whole radius list per anchor
// was O(anchors * radii), which a file with large anchor and radius arrays could drive
// into quadratic work. Duplicate record indices keep the maximum, matching the prior
// fold(0.0, f64::max) behavior.
let mut radius_by_record: std::collections::HashMap<usize, f64> = std::collections::HashMap::with_capacity(radii.len());
for &(rec, r) in radii {
let slot = radius_by_record.entry(rec).or_insert(0.0);
*slot = slot.max(r);
}
let corner = |a: &Anchor| radius_by_record.get(&a.record).copied().unwrap_or(0.0);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
+15
View File
@@ -283,6 +283,12 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Total raw `Chld` slots inspected across the whole traversal, including null slots and slots
/// reached through repeated references to a shared group. The node limit only counts produced
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
@@ -297,6 +303,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
active: HashSet<ObjId>,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
@@ -312,6 +319,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
child_slots: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -383,6 +391,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
// Charge every raw child slot (null slots included) against a document-wide budget
// before filtering. A shared group with a large null-filled Chld array could otherwise
// be rescanned through each reference without the node limit noticing.
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
+36 -7
View File
@@ -73,11 +73,40 @@ pub struct Object {
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
/// can be referenced by many sibling layers, and each reference used to rescan all of its
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
index: std::collections::HashMap<Tag, usize>,
}
impl Object {
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
let mut index = std::collections::HashMap::with_capacity(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
// Positional fields share tag 0 and are not looked up by tag; keep first-match
// semantics for real tags.
if t.0 != 0 {
index.entry(*t).or_insert(i);
}
}
Object { class, kind, chain, fields, index }
}
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
self.index.get(&tag).map(|&i| &self.fields[i].1)
}
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
/// tag index so `get` stays correct.
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
self.index.clear();
self.index.reserve(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
if t.0 != 0 {
self.index.entry(*t).or_insert(i);
}
}
self.fields = fields;
}
}
@@ -124,10 +153,10 @@ pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
o.set_fields(fields);
}
Ok(Stream { root, objects: p.objects })
}
@@ -252,7 +281,7 @@ impl Parser<'_> {
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
@@ -350,7 +379,7 @@ impl Parser<'_> {
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
Ok(Value::Obj(Some(id)))
}
@@ -369,7 +398,7 @@ impl Parser<'_> {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
@@ -395,7 +424,7 @@ impl Parser<'_> {
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object { class, kind: Kind::Shared, chain, fields };
*o = Object::new(class, Kind::Shared, chain, fields);
}
Ok(Value::Obj(Some(id)))
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
@@ -48,8 +48,10 @@ fn isolated(name: &str, check: impl FnOnce()) {
check();
return;
}
// The harness names a test by its module path below the crate root.
let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}"));
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", name, "--nocapture"])
.args(["--exact", &test, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, linked into one test binary.
mod compound_limits;
mod real_files;
File renamed without changes.
+8 -1
View File
@@ -33,6 +33,8 @@ fn main() {
depth_map: None,
},
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 12.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 100.0, threshold: 25.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::ShapeBlur { radius: 10.0, shape: BlurShape::Star },
FilterParams::TiltShift { blur: 15.0, center_x: 0.5, center_y: 0.5, angle: 0.0, focus: 0.1, transition: 0.15 },
FilterParams::IrisBlur { pins: vec![IrisPin::default()] },
@@ -60,7 +62,12 @@ fn main() {
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
println!("{:<18} {w}x{h} RGBA8: {:.2?}", p.label(), t.elapsed());
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
drop(out);
}
}
+17 -4
View File
@@ -127,10 +127,23 @@ fn main() {
photocraft_algo::puppet::ITERATIONS,
t.elapsed().as_secs_f64() * 1e3
);
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
let t = Instant::now();
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));
println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3);
// Compare the old path-dependent preview with the command-equivalent drag solve, using
// changing destinations and the same cached factorization (no remeshing per event).
for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] {
let mut previous = v.clone();
let mut times = Vec::new();
for step in 1..=60 {
let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
dst[1][0] -= f64::from(step) * 10.0;
dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0;
let t = Instant::now();
let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice()));
times.push(t.elapsed().as_secs_f64() * 1e3);
previous = std::hint::black_box(next);
}
times.sort_by(f64::total_cmp);
println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]);
}
let t = Instant::now();
let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic);
println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
+140 -25
View File
@@ -437,7 +437,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Chrome => gs(1.0 + g("smoothness") * 0.4) + 2,
F::ConteCrayon => 0,
F::GraphicPen => (g("strokeLength") / 2.0) as i32 + 2,
F::HalftonePattern => gs(0.5 + g("size") * 0.3),
F::HalftonePattern => gs(halftone_cell(e) * 0.15),
F::NotePaper => gs(1.0) + gs(1.2) + 2,
F::Photocopy => gs(1.0 + g("detail") * 0.5),
F::Plaster => gs(0.5 + g("smoothness") * 0.8) + gs(1.5) + 2,
@@ -454,6 +454,55 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
fn cutout_sigma(e: &GalleryEffect) -> f32 {
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
}
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
fn halftone_cell(e: &GalleryEffect) -> f32 {
1.5 + 2.0 * e.get("size").clamp(1.0, 12.0)
}
/// The round-dot spot function `(cos 2πu + cos 2πv) / 2` sampled over a cell and sorted: a
/// value's rank among them is the share of the cell below it, which makes the thresholds even.
fn halftone_dot_ranks() -> &'static [f32] {
static RANKS: std::sync::OnceLock<Vec<f32>> = std::sync::OnceLock::new();
RANKS.get_or_init(|| {
const N: usize = 128;
let mut v: Vec<f32> = (0..N * N)
.map(|i| {
let (u, w) = (((i % N) as f32 + 0.5) / N as f32, ((i / N) as f32 + 0.5) / N as f32);
((2.0 * PI * u).cos() + (2.0 * PI * w).cos()) / 2.0
})
.collect();
v.sort_by(f32::total_cmp);
v
})
}
/// For each tone (0 = paper … 1 = solid ink, 256 steps), the level `τ` at which the soft
/// threshold `clamp(0.5 + (τ − s) · gain)` over evenly spread thresholds `s` inks exactly that
/// share of a cell on average.
fn halftone_offsets(gain: f32) -> Vec<f32> {
let half = 0.5 / gain.max(1e-3);
// Average ink over s in [0, 1] for level τ: 1 below τ − half, a ramp to 0 at τ + half.
let cover = |tau: f32| {
let (a, z) = (tau - half, tau + half);
let solid = a.clamp(0.0, 1.0);
let (r0, r1) = (a.clamp(0.0, 1.0), z.clamp(0.0, 1.0));
let ramp = if r1 > r0 { (0.5 + tau * gain) * (r1 - r0) - 0.5 * gain * (r1 * r1 - r0 * r0) } else { 0.0 };
solid + ramp
};
(0..256)
.map(|i| {
let target = i as f32 / 255.0;
let (mut lo, mut hi) = (-half - 1.0, 1.0 + half + 1.0);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
if cover(mid) < target { lo = mid } else { hi = mid }
}
match i {
0 => -half - 1.0,
255 => 1.0 + half + 1.0,
_ => (lo + hi) / 2.0,
}
})
.collect()
}
fn daubs_radius(e: &GalleryEffect) -> usize {
let wide = matches!(e.choice("brushType"), "wideSharp" | "wideBlurry");
let r = e.get("brushSize") / 2.0 * if wide { 1.5 } else { 1.0 };
@@ -506,6 +555,7 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
res
}
/// Applies one gallery effect to the working window in place.
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
@@ -933,36 +983,46 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
}
}
F::HalftonePattern => {
let size = g("size");
let con = g("contrast") / 50.0;
let cell = 3.0 + size * 2.0;
// A halftone screen that keeps a continuous range of tones: each pixel compares its
// ink (the darkness of the image) with the screen's threshold there, and Contrast is
// how steeply: at 0 the image keeps its tones with the screen faintly through it, at
// 50 it is crisp dots, lines or rings. The thresholds are spread evenly over each cell,
// and each tone is offset so a cell's average ink is exactly that tone: black stays
// solid and white stays paper at any Contrast.
let cell = halftone_cell(e);
let gain = 0.5 + g("contrast") * 0.6;
let offsets = halftone_offsets(gain);
let mut l = win.lum();
blur1(&mut l, w, h, 0.5 + size * 0.3);
blur1(&mut l, w, h, cell * 0.15);
let b = ctx.bounds;
let (cx, cy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let ty = e.choice("patternType");
let dots = halftone_dot_ranks();
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let d = clamp01((1.0 - l[i] - 0.5) * (1.0 + con * 3.0) + 0.5);
let aa = 1.0 / cell;
let ink = match ty {
"circle" => {
let r = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt() / cell;
let q = (r.fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
"line" => {
let q = ((fy / cell).fract() - 0.5).abs() * 2.0;
smoothstep(d + aa, d - aa, q)
}
let band = |t: f32| ((t / cell).rem_euclid(1.0) - 0.5).abs() * 2.0;
let spot = match ty {
// Rings round the middle of the image, inked from the middle of each ring.
"circle" => band(((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt()),
// Horizontal lines, inked from the middle of each.
"line" => band(fy),
// Round dots on a screen at 45° (as a black halftone screen is), that grow
// from each cell's centre, meet at 50% as a checkerboard of squares square to
// the image, and fill the cell at 100%.
_ => {
let (u, v) = ((fx / cell).fract() - 0.5, (fy / cell).fract() - 0.5);
let q = (u * u + v * v).sqrt() * 2.0;
let rr = d.sqrt() * 1.15;
smoothstep(rr + aa, rr - aa, q)
let (rx, ry) = ((fx + fy) * std::f32::consts::FRAC_1_SQRT_2, (fy - fx) * std::f32::consts::FRAC_1_SQRT_2);
let (u, v) = ((rx / cell).rem_euclid(1.0), (ry / cell).rem_euclid(1.0));
let f = ((2.0 * PI * u).cos() + (2.0 * PI * v).cos()) / 2.0;
let k = dots.partition_point(|&r| r < f);
k as f32 / dots.len().max(1) as f32
}
};
let tone = clamp01(1.0 - l[i]) * (offsets.len() - 1) as f32;
let (k, f) = (tone.floor() as usize, tone.fract());
let at = |j: usize| offsets.get(j.min(offsets.len() - 1)).copied().unwrap_or(0.5);
let level = at(k) + (at(k + 1) - at(k)) * f;
let ink = clamp01(0.5 + (level - spot) * gain);
win.c[i] = mix(bg, fg, ink);
}
}
@@ -1254,6 +1314,43 @@ mod tests {
}
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
fn halftone_keeps_tones_and_contrast_sets_how_hard() {
// It used to be all ink or paper at any Contrast, with white crosses in the shadows
// (dots could never fill their cell) and mid-greys that came out the wrong darkness.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 60, 60);
let run = |grey: f32, pattern: &str, contrast: f32| {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
s.fill_rect(b, &[grey, grey, grey, 1.0]);
let mut e = GalleryEffect::new(G::HalftonePattern);
assert!(e.set_choice("patternType", pattern));
e.set("size", 4.0);
e.set("contrast", contrast);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
// Ink (1 = foreground black) of every pixel away from the canvas edge.
out.chunks(4).enumerate().filter(|(i, _)| (6..54).contains(&(i % 60)) && (6..54).contains(&(i / 60))).map(|(_, p)| 1.0 - p[0]).collect::<Vec<f32>>()
};
for pattern in ["dot", "circle", "line"] {
for contrast in [0.0, 5.0, 50.0] {
for grey in [0.2, 0.5, 0.8] {
let ink = run(grey, pattern, contrast);
let mean = ink.iter().sum::<f32>() / ink.len() as f32;
assert!((mean - (1.0 - grey)).abs() < 0.06, "{pattern} contrast {contrast}: grey {grey} inks {mean}");
}
let solid = run(0.0, pattern, contrast);
assert!(solid.iter().all(|&v| v > 0.99), "{pattern} contrast {contrast}: black isn't solid");
let paper = run(1.0, pattern, contrast);
assert!(paper.iter().all(|&v| v < 0.01), "{pattern} contrast {contrast}: white isn't paper");
}
let soft = run(0.5, pattern, 0.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
let hard = run(0.5, pattern, 50.0).iter().filter(|&&v| v > 0.05 && v < 0.95).count();
assert!(hard * 4 < soft, "{pattern}: contrast 50 should be much crisper ({hard} vs {soft} grey pixels)");
}
}
#[test]
fn params_parse_and_clamp() {
for f in crate::GalleryFilter::ALL {
@@ -1305,7 +1402,7 @@ mod tests {
}
/// Visual check: `GALLERY_RAW=src.raw GALLERY_W=480 GALLERY_H=322 cargo test -p photocraft-algo --release gallery_sheet -- --ignored`
/// writes `<key>.raw` (RGB8) beside the input for every filter.
/// writes `<key>.raw` (RGB8) beside the input for every filter (or one, see `GALLERY_ONLY`).
#[test]
#[ignore]
fn gallery_sheet() {
@@ -1318,11 +1415,29 @@ mod tests {
let b = Rect::new(0, 0, w, h);
s.write_region(b, &data);
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
for f in crate::GalleryFilter::ALL {
let fp = FilterParams::FilterGallery { effects: vec![GalleryEffect::new(*f)] };
// Optional: GALLERY_ONLY=<key> renders one filter; GALLERY_SET="key=value,key=name" sets its
// sliders (numbers) or choices (names); GALLERY_TAG is appended to the file name.
let only = std::env::var("GALLERY_ONLY").ok();
let set = std::env::var("GALLERY_SET").unwrap_or_default();
let tag = std::env::var("GALLERY_TAG").unwrap_or_default();
if let Some(k) = &only {
assert!(crate::GalleryFilter::ALL.iter().any(|f| f.key() == k), "GALLERY_ONLY={k} is not a gallery filter");
}
for f in crate::GalleryFilter::ALL.iter().filter(|f| only.as_deref().is_none_or(|k| k == f.key())) {
let mut e = GalleryEffect::new(*f);
for kv in set.split(',').filter(|kv| !kv.is_empty()) {
let (k, v) = kv.split_once('=').unwrap();
// `set` ignores unknown keys; a misspelt one would render the default unnoticed.
assert!(f.params().iter().any(|p| p.key == k), "{k} is not a parameter of {}", f.key());
match v.parse::<f32>() {
Ok(n) => e.set(k, n),
Err(_) => assert!(e.set_choice(k, v), "{k} has no choice {v}"),
}
}
let fp = FilterParams::FilterGallery { effects: vec![e] };
let out = crate::apply_in(&s, &fp, b, b, None, b).read_region(b);
let raw: Vec<u8> = out.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
std::fs::write(dir.join(format!("{}.raw", f.key())), raw).unwrap();
std::fs::write(dir.join(format!("{}{tag}.raw", f.key())), raw).unwrap();
}
}
}
+124 -21
View File
@@ -189,6 +189,11 @@ mod shape_tests;
/// Smart Blur: averages only neighbours within `threshold` levels of the
/// centre pixel (so edges stay crisp); Edge Only / Overlay Edge draw the
/// edges (where neighbours differ by more than the threshold) in white.
///
/// Neighbours outside `src` read as zero, as [`Image::get`] does. Pixels whose whole
/// kernel lies inside `src` (all of them, with the usual halo) read neighbours at
/// precomputed offsets into the window instead of by coordinates, and for up to five
/// channels go through [`smart_rows`] (#7921).
pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -201,31 +206,48 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let offs: Vec<(i32, i32)> = (-ri..=ri)
let win = src.rect;
let ww = win.width() as isize;
// Each offset with its distance in samples within the window.
let offs: Vec<(i32, i32, isize)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.map(|(dx, dy)| (dx, dy, (dy as isize * ww + dx as isize) * n as isize))
.collect();
let at = |x: i32, y: i32| win.contains(x, y).then(|| ((y - win.y0) as usize * ww as usize + (x - win.x0) as usize) * n);
let zeros = [0.0f32; MAXC];
let data = &src.data[..];
// The pixel at sample `i`, or zeros outside the window.
let sample = |i: Option<usize>| i.and_then(|i| data.get(i..i + n)).unwrap_or(&zeros[..n]);
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let mut q = [0.0f32; MAXC];
let inner = Rect::new(win.x0.saturating_add(ri), win.y0.saturating_add(ri), win.x1.saturating_sub(ri), win.y1.saturating_sub(ri)).intersect(&out);
let lin: Vec<isize> = offs.iter().map(|o| o.2).collect();
let rows = match (mode, n, ctx.alpha) {
(SmartBlurMode::Normal, 1, false) => smart_rows::<1, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 1, true) => smart_rows::<1, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, false) => smart_rows::<2, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 2, true) => smart_rows::<2, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, false) => smart_rows::<3, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 3, true) => smart_rows::<3, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, false) => smart_rows::<4, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 4, true) => smart_rows::<4, true>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, false) => smart_rows::<5, false>(src, out, inner, &lin, t, &mut res),
(SmartBlurMode::Normal, 5, true) => smart_rows::<5, true>(src, out, inner, &lin, t, &mut res),
_ => Rect::EMPTY,
};
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
if rows.contains(x, y) {
continue;
}
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
let differs = |q: &[f32]| q[..cc].iter().zip(&me).any(|(q, m)| (q - m).abs() > t);
if mode != SmartBlurMode::Normal {
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let edge = [(1, 0), (0, 1), (-1, 0), (0, -1)].into_iter().any(|(dx, dy)| differs(sample(at(x + dx, y + dy))));
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
@@ -237,18 +259,28 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
}
continue;
}
let interior = x - ri >= win.x0 && x + ri < win.x1 && y - ri >= win.y0 && y + ri < win.y1;
let centre = if interior { at(x, y) } else { None };
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
for &(dx, dy, d) in &offs {
let q = sample(match centre {
Some(c) => c.checked_add_signed(d),
None => at(x + dx, y + dy),
});
if differs(q) {
continue;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
if ctx.alpha {
let a = q[n - 1];
for (s, v) in acc.iter_mut().zip(&q[..cc]) {
*s += v * a;
}
acc[n - 1] += a;
} else {
for (s, v) in acc.iter_mut().zip(q) {
*s += v;
}
}
wsum += 1.0;
}
@@ -262,6 +294,77 @@ pub(crate) fn smart_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, thresho
res
}
/// Smart Blur's normal mode over `inner` (pixels whose whole kernel lies in `src`) for `N`
/// channels, the last alpha if `A`; `lin` are the neighbours as sample offsets in the window.
/// Each offset sweeps a whole row, so neighbours load contiguously and a rejected one adds
/// `-0.0` (an exact identity) instead of branching: every pixel sums its neighbours in the same
/// order as [`smart_blur`]'s per-pixel loop, giving the same bits. Returns the area written: the
/// rows finished, empty if the layout doesn't match.
fn smart_rows<const N: usize, const A: bool>(src: &Image, out: Rect, inner: Rect, lin: &[isize], t: f32, res: &mut [f32]) -> Rect {
let win = src.rect;
if src.ch != N || inner.is_empty() || !win.contains_rect(&inner) || !out.contains_rect(&inner) {
return Rect::EMPTY;
}
let cc = if A { N - 1 } else { N };
let (ww, ow, len) = (win.width() as usize, out.width() as usize, inner.width() as usize);
let pixels = len * N;
let mut acc = vec![[0.0f32; N]; len];
let mut wsum = vec![0.0f32; len];
// Rows before `y` are done; a row is written only once all its neighbours were read.
let done = |y: i32| Rect::new(inner.x0, inner.y0, inner.x1, y);
for y in inner.y0..inner.y1 {
let centre = ((y - win.y0) as usize * ww + (inner.x0 - win.x0) as usize) * N;
let Some(me) = src.data.get(centre..centre + pixels) else {
return done(y);
};
let me = me.as_chunks::<N>().0;
acc.fill([0.0; N]);
wsum.fill(0.0);
for &d in lin {
let Some(q) = centre.checked_add_signed(d).and_then(|i| src.data.get(i..i + pixels)) else {
return done(y);
};
for (((s, w), q), m) in acc.iter_mut().zip(wsum.iter_mut()).zip(q.as_chunks::<N>().0).zip(me) {
// As `differs`: a NaN difference is not over the threshold.
let mut far = false;
for c in 0..cc {
far |= (q[c] - m[c]).abs() > t;
}
let keep = !far;
if A {
let a = q[N - 1];
for c in 0..N - 1 {
s[c] += if keep { q[c] * a } else { -0.0 };
}
s[N - 1] += if keep { a } else { -0.0 };
} else {
for c in 0..N {
s[c] += if keep { q[c] } else { -0.0 };
}
}
*w += if keep { 1.0 } else { 0.0 };
}
}
let o = ((y - out.y0) as usize * ow + (inner.x0 - out.x0) as usize) * N;
let Some(dst) = res.get_mut(o..o + pixels) else {
return done(y);
};
for ((px, s), &w) in dst.as_chunks_mut::<N>().0.iter_mut().zip(&acc).zip(&wsum) {
if w > 0.0 {
for c in 0..N {
px[c] = s[c] / w;
}
unpremul_px(px, A);
}
}
}
inner
}
#[cfg(test)]
#[path = "smart_blur_tests.rs"]
mod smart_tests;
// ---------- lens blur ----------
pub(crate) struct LensSpec<'a> {
+19 -2
View File
@@ -411,6 +411,14 @@ pub enum FilterParams {
},
// ---- Render ----
/// Clouds (or Difference Clouds, which subtracts them from the pixels) between two straight
/// sRGB RGBA colours; the cloud size follows the bounds.
Clouds {
seed: u32,
difference: bool,
foreground: [f32; 4],
background: [f32; 4],
},
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
Fibers {
variance: f32,
@@ -606,7 +614,13 @@ impl FilterParams {
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
/// empty layer, while still reading no neighbourhood.
pub fn renders_from_nothing(&self) -> bool {
matches!(self, FilterParams::Fibers { .. })
matches!(self, FilterParams::Fibers { .. } | FilterParams::Clouds { difference: false, .. })
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
}
/// Human-readable name.
@@ -653,6 +667,8 @@ impl FilterParams {
FilterParams::Displace { .. } => "Displace",
FilterParams::Shear { .. } => "Shear",
FilterParams::ZigZag { .. } => "ZigZag",
FilterParams::Clouds { difference: false, .. } => "Clouds",
FilterParams::Clouds { difference: true, .. } => "Difference Clouds",
FilterParams::Fibers { .. } => "Fibers",
FilterParams::LensFlare { .. } => "Lens Flare",
FilterParams::LightingEffects { .. } => "Lighting Effects",
@@ -697,7 +713,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Wind { method, .. } => r(stylize2::wind_reach(*method)),
FilterParams::Displace { .. } | FilterParams::Shear { .. } | FilterParams::ZigZag { .. } | FilterParams::SpinBlur { .. } => Halo::Bounds,
// Generators and per-pixel renders only need the bounds geometry (always in the context).
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } | FilterParams::Clouds { .. } => Halo::Radius(0),
FilterParams::LightingEffects { .. } => r(1.0),
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
@@ -817,6 +833,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
FilterParams::Clouds { seed, difference, foreground, background } => render::clouds(src, out, ctx, *seed, *difference, (*foreground, *background)),
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
+81 -19
View File
@@ -19,6 +19,7 @@
//! copied untouched. [`ProxyImage::render`] is the cheap preview path: bilinear field and
//! bilinear source on a downsampled RGBA copy, re-rendered only where a dab changed things.
use crate::par;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
@@ -186,6 +187,10 @@ struct StrokeCursor {
carry: f64,
}
/// Dabs over this many field nodes compute their rows in parallel (an 800 px brush has about
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -487,9 +492,11 @@ impl LiquifyField {
bilinear(&self.d, self.w, self.h, gx, gy)
}
};
let mut writes: Vec<(usize, [f32; 2], f32)> = Vec::with_capacity((i1 - i0 + 1) * (j1 - j0 + 1));
// Every node reads only the snapshot and the field as it was before this dab (the writes
// land after), so rows are independent: a large brush computes them in parallel.
let inv_cell = 1.0 / self.cell;
for j in j0..=j1 {
let node_row = |j: usize| -> Vec<(usize, [f32; 2], f32)> {
let mut row = Vec::with_capacity(i1 - i0 + 1);
for i in i0..=i1 {
let p = self.node_pos(i, j);
let (dx, dy) = (p[0] - c[0], p[1] - c[1]);
@@ -503,7 +510,7 @@ impl LiquifyField {
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
let f = self.freeze[idx];
let v = if s.tool == LiquifyTool::Freeze { (f as f64 + k_raw).min(1.0) } else { (f as f64 - k_raw).max(0.0) };
writes.push((idx, self.d[idx], v as f32));
row.push((idx, self.d[idx], v as f32));
continue;
}
let k = k_raw * (1.0 - f64::from(self.freeze[idx]));
@@ -553,10 +560,13 @@ impl LiquifyField {
[(q[0] + dq[0] - p[0]) as f32, (q[1] + dq[1] - p[1]) as f32]
}
};
writes.push((idx, new, self.freeze[idx]));
row.push((idx, new, self.freeze[idx]));
}
}
for (idx, d, f) in writes {
row
};
let rows = j1 - j0 + 1;
let writes = par::map(rows, rows * (i1 - i0 + 1) > PARALLEL_DAB_NODES, |row| node_row(j0 + row));
for (idx, d, f) in writes.into_iter().flatten() {
self.d[idx] = d;
self.freeze[idx] = f;
}
@@ -653,7 +663,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
if field.is_identity() || b.is_empty() {
return out;
}
let maxd = f64::from(field.max_displacement()).ceil() as i32 + 3;
// A 1D Catmull-Rom stencil's absolute weights sum to at most 5/4, so the 2D
// interpolated displacement can reach 25/16 of the largest nodal displacement.
let maxd = ((f64::from(field.max_displacement()) * (25.0 / 16.0)).ceil() as i32).saturating_add(3);
let has_freeze = field.freeze.iter().any(|&f| f > 0.0);
let tiles: Vec<Rect> = b.tiles().map(|t| t.rect().intersect(&b)).filter(|r| !r.is_empty() && field.touches(*r)).collect();
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
@@ -670,7 +683,15 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let d = field.sample_cubic(gx, gy, &wx, &wy);
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
let linear = field.sample(px, py);
d[0] = d[0] * (1.0 - mask) + linear[0] * mask;
d[1] = d[1] * (1.0 - mask) + linear[1] * mask;
}
}
if d[0] != 0.0 || d[1] != 0.0 {
moved = true;
}
@@ -920,18 +941,23 @@ impl ProxyImage {
return false;
}
source_scratch.resize(source_len, [0.0; 4]);
src.read_rgba_into(src_rect, source_scratch);
if !src.format().alpha {
for pixel in source_scratch.iter_mut() {
pixel[3] = 1.0;
// Read and premultiply the source in one pass per band of rows, the bands in parallel: at
// an 800 px brush it is about a million pixels (P30).
let opaque = !src.format().alpha;
const BAND_ROWS: usize = 32;
par::chunks_mut(source_scratch, sw * BAND_ROWS, true, |band_index, band| {
let y = src_rect.y0 + (band_index * BAND_ROWS) as i32;
src.read_rgba_into(Rect::new(src_rect.x0, y, src_rect.x1, y + (band.len() / sw) as i32), band);
for pixel in band.iter_mut() {
if opaque {
pixel[3] = 1.0;
}
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
}
}
for pixel in source_scratch.iter_mut() {
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
}
});
let x0 = (region.x0 - bounds.x0) as usize;
let x1 = (region.x1 - bounds.x0) as usize;
@@ -1376,6 +1402,42 @@ mod tests {
assert!(g.freeze.iter().all(|f| *f == 0.0));
}
#[test]
fn fully_frozen_pixels_keep_their_mapping_when_an_adjacent_area_is_warped() {
let bounds = bounds();
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for y in 0..bounds.height() {
for x in 0..bounds.width() {
src.write_pixel(x as i32, y as i32, &[x as f32 / bounds.width() as f32, y as f32 / bounds.height() as f32, 0.0, 1.0]);
}
}
let mut mask_only = LiquifyField::new(bounds, 2.0);
mask_only.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
assert_eq!(apply_liquify(&src, &mask_only), src);
let mut field = LiquifyField::new(bounds, 2.0);
let mut first_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
first_warp.points = vec![vec![22.0, 32.0], vec![34.0, 32.0]];
field.apply_stroke(&first_warp);
field.apply_stroke(&lasso(&[[0.0, 0.0], [40.0, 0.0], [40.0, 64.0], [0.0, 64.0]], None));
let frozen_pixel = (37, 32);
assert!(field.freeze_at(f64::from(frozen_pixel.0) + 0.5, f64::from(frozen_pixel.1) + 0.5) > 0.999);
let before = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
assert_ne!(before, src.pixel(frozen_pixel.0, frozen_pixel.1), "freezing must retain the earlier warp");
let neighbor_before = apply_liquify(&src, &field).pixel(43, 32);
let mut adjacent_warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 24.0);
adjacent_warp.points = vec![vec![44.0, 32.0], vec![52.0, 32.0]];
field.apply_stroke(&adjacent_warp);
let after = apply_liquify(&src, &field).pixel(frozen_pixel.0, frozen_pixel.1);
let neighbor_after = apply_liquify(&src, &field).pixel(43, 32);
assert_eq!(before, after, "a fully frozen pixel must keep its pre-neighbor-warp mapping");
assert_ne!(neighbor_before, neighbor_after, "an adjacent unfrozen pixel must still move");
}
#[test]
fn twirl_pucker_bloat_and_push_left_have_the_right_sense() {
let at = |tool: LiquifyTool, pts: Vec<Vec<f64>>| {
+173
View File
@@ -124,6 +124,179 @@ pub(crate) fn hsb_hsl(src: &Image, out: Rect, ctx: &Ctx, input: HsbModel, output
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{PixelFormat, SampleType};
use photocraft_raster::Surface;
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
fn custom_fixture(depth: SampleType, mode: ColorMode, alpha: bool) -> (Surface, Rect) {
let b = Rect::new(-2, -1, 3, 4);
let format = PixelFormat::new(mode, depth, alpha);
let mut src = Surface::new(format);
let mut pixels = Vec::new();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
for c in 0..format.channels() {
pixels.push(((x - b.x0) + 5 * (y - b.y0) + 3 * c as i32) as f32 / 40.0);
}
}
}
src.write_region(b, &pixels);
(src, b)
}
fn assert_sample(actual: f32, expected: f32, depth: SampleType) {
let (expected, tolerance) = match depth {
SampleType::U8 => (expected.clamp(0.0, 1.0), 1.0 / 255.0),
SampleType::U16 => (expected.clamp(0.0, 1.0), 1.0 / 65535.0),
SampleType::F32 => (expected, 1e-6),
};
assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} != {expected}");
}
fn run_custom(src: &Surface, b: Rect, kernel: Vec<f32>, scale: f32, offset: f32) -> Surface {
crate::apply_in(src, &crate::FilterParams::Custom { kernel, scale, offset }, b, b, None, b)
}
#[test]
fn custom_all_25_taps_use_row_major_offsets_and_repeat_canvas_edges() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
for tap in 0..25 {
let mut kernel = vec![0.0; 25];
kernel[tap] = 1.0;
let out = run_custom(&src, b, kernel, 1.0, 0.0);
let (dx, dy) = (tap as i32 % 5 - 2, tap as i32 / 5 - 2);
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let expected = src.pixel((x + dx).clamp(b.x0, b.x1 - 1), (y + dy).clamp(b.y0, b.y1 - 1));
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], expected[c], depth);
}
assert_eq!(actual[3], src.pixel(x, y)[3], "tap={tap}, alpha must not shift");
}
}
}
}
}
#[test]
fn custom_short_kernel_zero_pads_and_ignores_entries_after_25() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
let mut short = vec![0.0; 13];
short[0] = 0.25;
short[12] = 0.75;
let mut padded = short.clone();
padded.resize(25, 0.0);
let mut long = padded.clone();
long.extend([1e6, -1e6, f32::NAN]);
let out = run_custom(&src, b, short, 1.0, 0.0);
assert_eq!(out.read_region(b), run_custom(&src, b, padded, 1.0, 0.0).read_region(b));
assert_eq!(out.read_region(b), run_custom(&src, b, long, 1.0, 0.0).read_region(b));
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let corner = src.pixel((x - 2).max(b.x0), (y - 2).max(b.y0));
let centre = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], 0.25 * corner[c] + 0.75 * centre[c], depth);
}
}
}
}
}
#[test]
fn custom_scale_zero_is_one_and_offset_is_in_255ths() {
for depth in DEPTHS {
let (src, b) = custom_fixture(depth, ColorMode::Rgb, true);
let before = src.read_region(b);
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel.clone(), 0.0, -51.0);
assert_eq!(out.read_region(b), run_custom(&src, b, kernel, 1.0, -51.0).read_region(b));
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let original = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..3 {
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
}
assert_eq!(actual[3], original[3]);
}
}
assert_eq!(src.read_region(b), before);
}
}
#[test]
fn custom_neighbourhood_convolution_preserves_each_pixels_alpha() {
for depth in DEPTHS {
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
let (src, b) = custom_fixture(depth, mode, true);
let out = run_custom(&src, b, vec![1.0; 25], 25.0, 0.0);
let alpha = src.channels() - 1;
assert_ne!(out.pixel(b.x0, b.y0)[0], src.pixel(b.x0, b.y0)[0], "fixture must change colour");
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
assert_eq!(out.pixel(x, y)[alpha], src.pixel(x, y)[alpha], "{depth:?}, {mode:?}, {x},{y}");
}
}
}
}
}
#[test]
fn custom_cmyk_convolves_brightness_including_black_with_or_without_alpha() {
for depth in DEPTHS {
for alpha in [false, true] {
let (src, b) = custom_fixture(depth, ColorMode::Cmyk, alpha);
for offset in [-51.0, 51.0] {
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel, 1.0, offset);
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let original = src.pixel(x, y);
let actual = out.pixel(x, y);
for c in 0..4 {
// Twice the reflected brightness, plus offset, then back to ink.
assert_sample(actual[c], 2.0 * original[c] - 1.0 - offset / 255.0, depth);
}
if alpha {
assert_eq!(actual[4], original[4]);
}
}
}
}
}
}
}
#[test]
fn custom_float_retains_hdr_while_integer_storage_clamps() {
let b = Rect::new(0, 0, 1, 1);
for depth in DEPTHS {
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
src.fill_rect(b, &[0.0, 0.5, 1.0, 0.4]);
let mut kernel = vec![0.0; 25];
kernel[12] = 2.0;
let out = run_custom(&src, b, kernel, 1.0, -51.0);
let actual = out.pixel(0, 0);
let original = src.pixel(0, 0);
for c in 0..3 {
assert_sample(actual[c], 2.0 * original[c] - 0.2, depth);
}
assert_eq!(actual[3], original[3]);
if depth == SampleType::F32 {
assert!(actual[0] < 0.0 && actual[2] > 1.0);
} else {
assert_eq!((actual[0], actual[2]), (0.0, 1.0));
}
}
}
#[test]
fn hsb_hsl_round_trip() {
+92 -5
View File
@@ -897,14 +897,39 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
label[i] = img as i32;
}
}
if no == 0 || nn == no {
if no == 0 {
continue;
}
let (ox, oy) = (sx / no as f64, sy / no as f64);
let (cx, cy) = (nx / nn as f64, ny / nn as f64);
let contained = nn == no;
let (ox, oy) = if contained {
// Wholly inside the composite (#5745: two photos in one document, the second moved
// by Auto-Align so the canvas crops it to the part over the first). Its side of the
// seam is the one it lies towards from the composite's centre; an image centred on
// the composite has no side, and the earlier images keep it.
let (mut px, mut py, mut pn) = (0.0f64, 0.0f64, 0usize);
for (i, l) in label.iter().enumerate() {
if *l >= 0 {
px += (i % w) as f64;
py += (i / w) as f64;
pn += 1;
}
}
let (px, py) = (px / pn.max(1) as f64, py / pn.max(1) as f64);
if (cx - px).abs() < 0.5 && (cy - py).abs() < 0.5 {
continue;
}
(px, py)
} else {
(sx / no as f64, sy / no as f64)
};
let vertical = (cx - ox).abs() >= (cy - oy).abs();
// Image on the increasing side along the seam's cross axis?
let positive = if vertical { cx > ox } else { cy > oy };
// A contained image has no pixels of its own, so a seam on its outer edge would leave it
// nothing: break cost ties away from its side (towards larger k when it lies on the
// decreasing side) so it keeps its side of the overlap.
let prefer_high = contained && !positive;
let cost = |i: usize| -> f32 { 0.002 + (luma[img][i] - luma[label[i] as usize][i]).abs() };
let (lines, len) = if vertical { (h, w) } else { (w, h) };
let idx = |line: usize, k: usize| if vertical { line * w + k } else { k * w + line };
@@ -925,7 +950,8 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
let mut best = (0.0f32, usize::MAX);
if let Some(pl) = prev_line.filter(|pl| *pl + 1 == line) {
best = (f32::INFINITY, usize::MAX);
for dk in [-1i64, 0, 1] {
let dks = if prefer_high { [1i64, 0, -1] } else { [-1i64, 0, 1] };
for dk in dks {
let kk = k as i64 + dk;
if kk < 0 || kk >= len as i64 {
continue;
@@ -952,7 +978,12 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if seam[line] != usize::MAX {
continue;
}
let Some(k) = (0..len).filter(|&k| overlap[idx(line, k)]).min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)])) else { continue };
let Some(k) = (0..len)
.filter(|&k| overlap[idx(line, k)])
.min_by(|&a, &b| acc[idx(line, a)].total_cmp(&acc[idx(line, b)]).then(if prefer_high { b.cmp(&a) } else { a.cmp(&b) }))
else {
continue;
};
let (mut l, mut k) = (line, k);
loop {
seam[l] = k;
@@ -972,9 +1003,13 @@ pub fn seam_labels(w: usize, h: usize, luma: &[Vec<f32>], cover: &[Vec<bool>], o
if s == usize::MAX {
continue;
}
let on_side = |k: usize| if positive { k > s } else { k < s };
// A contained image has no other cells, so where the overlap has nothing on its side
// of the seam (a line one cell wide) it takes the seam cell rather than nothing.
let take_seam = contained && !(0..len).any(|k| overlap[idx(line, k)] && on_side(k));
for k in 0..len {
let i = idx(line, k);
if overlap[i] && ((positive && k > s) || (!positive && k < s)) {
if overlap[i] && (on_side(k) || (take_seam && k == s)) {
label[i] = img as i32;
}
}
@@ -1585,6 +1620,58 @@ mod tests {
assert!(max_jump < 0.1, "max step {max_jump}");
assert!(cov.iter().all(|c| *c == 1.0));
}
#[test]
fn an_image_wholly_inside_the_composite_still_gets_its_side() {
// #5745: the first image covers the whole 120×40 grid, the second only its right part
// (x ≥ 70), all of it overlap. It used to be skipped, so the first image kept everything.
let (w, h) = (120usize, 40usize);
let luma = vec![vec![0.5f32; w * h], vec![0.5f32; w * h]];
let cover: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w >= 70).collect()];
let labels = seam_labels(w, h, &luma, &cover, &[0, 1]);
for y in 0..h {
assert_eq!(labels[y * w + 5], 0, "row {y}");
assert_eq!(labels[y * w + w - 1], 1, "row {y}");
// The second image's region is one run at the right of its seam.
let row = &labels[y * w..(y + 1) * w];
let first = row.iter().position(|l| *l == 1).unwrap();
assert!(first >= 70 && row[first..].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
// The same on the left (x < 50) and on top (y < 15): the seam stays inside the overlap
// instead of on the image's outer edge, which used to leave it nothing.
let left: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w < 50).collect()];
let labels = seam_labels(w, h, &luma, &left, &[0, 1]);
for y in 0..h {
let row = &labels[y * w..(y + 1) * w];
assert_eq!(row[0], 1, "row {y}: {row:?}");
assert_eq!(row[w - 1], 0, "row {y}");
let last = row.iter().rposition(|l| *l == 1).unwrap();
assert!(last < 50 && row[..=last].iter().all(|l| *l == 1), "row {y}: {row:?}");
}
let (tw, th) = (40usize, 120usize);
let tluma = vec![vec![0.5f32; tw * th], vec![0.5f32; tw * th]];
let top: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw < 50).collect()];
let labels = seam_labels(tw, th, &tluma, &top, &[0, 1]);
for x in 0..tw {
assert_eq!(labels[x], 1, "column {x}");
assert_eq!(labels[(th - 1) * tw + x], 0, "column {x}");
}
// One column or row wide: the seam can only run through it, and the image keeps it.
for col in [0, w - 1] {
let strip: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| i % w == col).collect()];
let labels = seam_labels(w, h, &luma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i % w == col)), "column {col}: {labels:?}");
}
let strip: Vec<Vec<bool>> = vec![vec![true; tw * th], (0..tw * th).map(|i| i / tw == 0).collect()];
let labels = seam_labels(tw, th, &tluma, &strip, &[0, 1]);
assert!(labels.iter().enumerate().all(|(i, l)| *l == i32::from(i / tw == 0)), "{labels:?}");
// An image centred on the composite has no side: the first image keeps the overlap.
let centred: Vec<Vec<bool>> = vec![vec![true; w * h], (0..w * h).map(|i| (20..100).contains(&(i % w))).collect()];
assert!(seam_labels(w, h, &luma, &centred, &[0, 1]).iter().all(|l| *l == 0));
// Two images covering the same cells, likewise.
let same = vec![vec![true; w * h]; 2];
assert!(seam_labels(w, h, &luma, &same, &[0, 1]).iter().all(|l| *l == 0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
+401 -26
View File
@@ -61,8 +61,15 @@ pub enum Dither {
Noise,
}
/// `(v.clamp(0, 1) * 255).round() as u8`, without the libm `roundf` call per channel (that call
/// was about a tenth of the palette scan on a large image). `x` is in 0..=255, so truncating
/// with an integer cast and adding one when the exact remainder is at least a half is
/// round-half-away-from-zero, the same as `f32::round`; NaN gives 0 in both. The
/// `to8_matches_round_for_every_float` test checks every input.
fn to8(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
let x = v.clamp(0.0, 1.0) * 255.0;
let t = x as u32;
(t + u32::from(x - t as f32 >= 0.5)) as u8
}
fn rgb8(p: &[f32; 4]) -> Rgb8 {
@@ -166,15 +173,116 @@ fn forced_colors(f: Forced) -> Vec<Rgb8> {
}
}
/// Unique 8-bit colours with counts (opaque pixels only).
/// Unique 8-bit colours with counts (opaque pixels only), sorted by colour.
fn histogram(px: &[[f32; 4]]) -> Vec<(Rgb8, u32)> {
let mut m: std::collections::HashMap<Rgb8, u32> = std::collections::HashMap::new();
for p in px.iter().filter(|p| p[3] >= 0.5) {
*m.entry(rgb8(p)).or_default() += 1;
// Pack each opaque pixel into one u32 (r, g, b from the top, so the numeric order is the
// `[u8; 3]` order), sort the plain integers and count the runs. This replaces a SipHash
// `HashMap` insert per pixel: counts are integers, so any order of work gives the same
// result, and equal colours sit next to each other after the sort. One 4-byte value per
// pixel is the only large allocation (no per-thread tables).
let pack = |p: &[f32; 4]| {
let [r, g, b] = rgb8(p);
u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b)
};
#[cfg(not(target_arch = "wasm32"))]
let keys = {
use rayon::prelude::*;
let mut keys: Vec<u32> = px.par_iter().filter(|p| p[3] >= 0.5).map(pack).collect();
keys.par_sort_unstable();
keys
};
#[cfg(target_arch = "wasm32")]
let keys = {
let mut keys: Vec<u32> = px.iter().filter(|p| p[3] >= 0.5).map(pack).collect();
keys.sort_unstable();
keys
};
let mut out: Vec<(Rgb8, u32)> = Vec::new();
for k in keys {
let c = [(k >> 16) as u8, (k >> 8) as u8, k as u8];
match out.last_mut() {
Some((last, n)) if *last == c => *n = n.saturating_add(1),
_ => out.push((c, 1)),
}
}
out
}
/// Widest axis of `b` and its split score (weighted extent), or `None` for a box that cannot
/// be split. Every score is bit for bit what the old per-iteration scan gave.
fn split_score(b: &[([f32; 3], u32)]) -> Option<(usize, f32)> {
if b.len() <= 1 {
return None;
}
// One pass per axis, then the weight sum: the same folds the old per-iteration scan ran
// (measured faster than a single fused pass, which the compiler does not vectorise).
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
Some((best.0, best.1 * w.sqrt()))
}
/// Maps an f32 to a u32 whose unsigned order is exactly `f32::total_cmp` order.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits();
// Negative: flip every bit (reverses their order, puts them below). Positive: set the sign bit.
bits ^ ((((bits as i32) >> 31) as u32) | 0x8000_0000)
}
/// Stable LSD radix sort of `b` along `axis`, in the order of `f32::total_cmp`. Stable like
/// `sort_by`, so ties keep their original order and the result is element for element the same;
/// it is several times faster than a comparison sort on millions of points.
fn radix_sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
let n = b.len();
let Some(&fill) = b.first() else { return };
if n < 2 {
return;
}
// Three digits of 11, 11 and 10 bits; count all of them in one read.
let mut counts = [[0u32; 2048]; 3];
for e in b.iter() {
let k = total_order_key(e.0[axis]);
counts[0][(k & 0x7ff) as usize] += 1;
counts[1][((k >> 11) & 0x7ff) as usize] += 1;
counts[2][(k >> 22) as usize] += 1;
}
let mut src = std::mem::take(b);
let mut dst = vec![fill; n];
for (pass, count) in counts.iter_mut().enumerate() {
// A digit that is the same for every point would not move anything.
if count.iter().any(|&c| c as usize == n) {
continue;
}
let mut sum = 0u32;
for c in count.iter_mut() {
let here = *c;
*c = sum;
sum += here;
}
let shift = 11 * pass as u32;
for e in &src {
let d = ((total_order_key(e.0[axis]) >> shift) & 0x7ff) as usize;
dst[count[d] as usize] = *e;
count[d] += 1;
}
std::mem::swap(&mut src, &mut dst);
}
*b = src;
}
/// Stable sort of `b` along `axis`. Boxes this small are quicker with `sort_by` than with the
/// radix sort's fixed counting cost.
fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
if b.len() >= 256 {
radix_sort_along(b, axis);
} else {
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
}
let mut v: Vec<(Rgb8, u32)> = m.into_iter().collect();
v.sort_unstable();
v
}
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
@@ -183,29 +291,23 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
return Vec::new();
}
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
// Each box's (axis, score), kept in lockstep with `boxes` (same swap_remove and pushes).
// Only the two boxes made by a split change, so only those are scanned again; rescanning
// every box on every iteration was most of the time here.
let mut scores: Vec<Option<(usize, f32)>> = vec![split_score(pts)];
while boxes.len() < n {
// Split the box with the largest weighted extent.
let (bi, axis, score) = boxes
// Split the box with the largest weighted extent (first one wins a tie, as before).
let (bi, axis, score) = scores
.iter()
.enumerate()
.filter(|(_, b)| b.len() > 1)
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.filter_map(|(i, s)| s.map(|(axis, score)| (i, axis, score)))
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
let mut b = boxes.swap_remove(bi);
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
scores.swap_remove(bi);
sort_along(&mut b, axis);
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
let mut acc = 0u64;
let mut cut = 1;
@@ -217,6 +319,8 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
}
}
let rest = b.split_off(cut);
scores.push(split_score(&b));
scores.push(split_score(&rest));
boxes.push(b);
boxes.push(rest);
}
@@ -239,8 +343,18 @@ fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
let lookup = Nearest::new(centers.iter().copied());
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
for (p, w) in pts {
let i = lookup.find(*p);
// The nearest-centre search is the expensive part and has no cross-point state, so it
// runs in parallel. The f64 sums below stay sequential in the original point order:
// float addition is not associative, and this keeps every bit of the result unchanged.
#[cfg(not(target_arch = "wasm32"))]
let nearest: Vec<u32> = {
use rayon::prelude::*;
pts.par_iter().map(|(p, _)| lookup.find(*p) as u32).collect()
};
#[cfg(target_arch = "wasm32")]
let nearest: Vec<u32> = pts.iter().map(|(p, _)| lookup.find(*p) as u32).collect();
for ((p, w), &i) in pts.iter().zip(&nearest) {
let i = i as usize;
for a in 0..3 {
sum[i][a] += f64::from(p[a]) * f64::from(*w);
}
@@ -258,6 +372,20 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 {
photocraft_color::convert::lab_to_srgb(lab).map(to8)
}
/// Histogram colours as CIE Lab points with their counts. Each point depends only on its own
/// colour and `collect` keeps the order, so the parallel map gives the same list; the log/pow
/// in `srgb_to_lab` made this a visible serial cost with millions of unique colours.
fn lab_points(hist: &[(Rgb8, u32)]) -> Vec<([f32; 3], u32)> {
let lab = |(c, n): &(Rgb8, u32)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n);
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
hist.par_iter().map(lab).collect()
}
#[cfg(target_arch = "wasm32")]
hist.iter().map(lab).collect()
}
/// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed
/// kinds; forced colours are included first and count toward it.
pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result<Vec<Rgb8>, String> {
@@ -296,7 +424,7 @@ pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced:
if hist.len() <= room {
hist.iter().map(|h| h.0).collect()
} else {
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect();
let pts = lab_points(&hist);
let mut centers = median_cut(&pts, room);
kmeans(&pts, &mut centers, 4);
let mut v: Vec<Rgb8> = centers.iter().map(|c| lab_to_rgb8(*c)).collect();
@@ -610,6 +738,253 @@ mod tests {
(0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect()
}
fn to8_reference(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
#[test]
fn to8_matches_round_on_samples_and_edges() {
// Every half-way point and its neighbours, plus a dense sweep of the unit range.
for k in 0..=256u32 {
let mid = (k as f32 + 0.5) / 255.0;
for d in -3i32..=3 {
let v = f32::from_bits((mid.to_bits() as i32 + d) as u32);
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
}
for i in 0..200_000u32 {
let v = f32::from_bits(i * 20_000);
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, 1.0, -1.0, 2.0, 1e30, -1e30, f32::MIN_POSITIVE, 1e-45] {
assert_eq!(to8(v), to8_reference(v), "{v:e}");
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
#[ignore = "checks all 2^32 floats, a few seconds in release; run with --release --ignored"]
fn to8_matches_round_for_every_float() {
use rayon::prelude::*;
let bad = (0..=u32::MAX).into_par_iter().filter(|&b| to8(f32::from_bits(b)) != to8_reference(f32::from_bits(b))).count();
assert_eq!(bad, 0);
}
/// A repeatable 96x64 image: a gradient with up to 31 levels of integer-hash noise and a
/// few see-through pixels.
fn golden_image() -> Vec<[f32; 4]> {
let (w, h) = (96usize, 64usize);
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let noise = |salt: u32| ((((i as u32).wrapping_mul(2_654_435_761).wrapping_add(salt.wrapping_mul(40_503))) >> 13) & 31) as f32 / 255.0;
let alpha = if i % 17 == 0 { 0.25 } else { 1.0 };
[x / (w - 1) as f32 * 0.8 + noise(1), y / (h - 1) as f32 * 0.7 + noise(2), ((x + y) / (w + h) as f32) * 0.6 + noise(3), alpha]
})
.collect()
}
/// Palettes written down from the version before the speed-up (hash-map histogram, every box
/// rescanned each split, comparison sort, serial k-means). Any change to how the palettes
/// are built has to give these exact colours.
#[test]
fn palettes_match_the_recorded_originals() {
let px = golden_image();
#[rustfmt::skip]
let cases: Vec<(PaletteKind, usize, Forced, &[Rgb8])> = vec![
(
PaletteKind::Adaptive,
16,
Forced::None,
&[
[40, 173, 80], [196, 175, 150], [92, 173, 103], [92, 81, 71], [195, 83, 118], [39, 82, 49],
[142, 82, 95], [195, 37, 101], [143, 36, 79], [40, 128, 64], [196, 129, 133], [92, 127, 87],
[91, 36, 56], [38, 35, 33], [144, 128, 111], [144, 174, 127],
],
),
(
PaletteKind::Perceptual,
16,
Forced::None,
&[
[104, 111, 87], [51, 129, 68], [48, 178, 79], [110, 74, 77], [165, 46, 93], [205, 50, 107],
[200, 154, 143], [44, 54, 42], [190, 101, 121], [61, 32, 44], [155, 115, 111], [48, 93, 56],
[119, 42, 71], [84, 172, 98], [126, 165, 115], [172, 168, 135],
],
),
(
PaletteKind::Selective,
24,
Forced::BlackWhite,
&[
[0, 0, 0], [255, 255, 255], [102, 107, 85], [65, 69, 55], [87, 133, 89], [109, 73, 76],
[164, 42, 92], [204, 51, 102], [199, 153, 142], [54, 170, 84], [199, 107, 126], [65, 33, 45],
[153, 102, 102], [187, 180, 145], [40, 106, 54], [100, 176, 107], [138, 174, 123], [50, 188, 80],
[32, 37, 33], [47, 139, 69], [144, 143, 116], [41, 80, 49], [108, 33, 64], [151, 70, 94],
],
),
(
PaletteKind::Adaptive,
8,
Forced::Primaries,
&[
[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255],
[255, 0, 255], [255, 255, 0],
],
),
(
PaletteKind::Perceptual,
12,
Forced::BlackWhite,
&[
[0, 0, 0], [255, 255, 255], [104, 106, 85], [137, 43, 79], [148, 170, 126], [181, 144, 132],
[66, 172, 87], [88, 49, 60], [57, 118, 67], [42, 54, 41], [183, 101, 118], [196, 50, 105],
],
),
];
for (kind, colors, forced, expected) in cases {
assert_eq!(build_palette(&px, kind, colors, forced).unwrap(), expected, "{kind:?} {colors} {forced:?}");
}
}
/// The pre-speed-up median cut and k-means, kept as the oracle for the faster ones.
fn median_cut_reference(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
if pts.is_empty() || n == 0 {
return Vec::new();
}
let mut boxes: Vec<Vec<([f32; 3], u32)>> = vec![pts.to_vec()];
while boxes.len() < n {
let (bi, axis, score) = boxes
.iter()
.enumerate()
.filter(|(_, b)| b.len() > 1)
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
}
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
let mut b = boxes.swap_remove(bi);
b.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
let total: u64 = b.iter().map(|p| u64::from(p.1)).sum();
let mut acc = 0u64;
let mut cut = 1;
for (i, p) in b.iter().enumerate() {
acc += u64::from(p.1);
if acc * 2 >= total {
cut = (i + 1).clamp(1, b.len() - 1);
break;
}
}
let rest = b.split_off(cut);
boxes.push(b);
boxes.push(rest);
}
boxes
.iter()
.map(|b| {
let w: f64 = b.iter().map(|p| f64::from(p.1)).sum();
std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::<f64>() / w) as f32)
})
.collect()
}
fn kmeans_reference(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
for _ in 0..iters {
let lookup = Nearest::new(centers.iter().copied());
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
for (p, w) in pts {
let i = lookup.find(*p);
for a in 0..3 {
sum[i][a] += f64::from(p[a]) * f64::from(*w);
}
cnt[i] += f64::from(*w);
}
for (i, c) in centers.iter_mut().enumerate() {
if cnt[i] > 0.0 {
*c = std::array::from_fn(|a| (sum[i][a] / cnt[i]) as f32);
}
}
}
}
/// Points with many repeated coordinates (ties), negative values, -0.0 and weights up to
/// a few thousand, so the stable-sort and float-order rules are all exercised.
fn tie_heavy_points(n: usize, seed: u64) -> Vec<([f32; 3], u32)> {
let mut s = seed;
let mut next = move || {
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(s >> 33) as u32
};
(0..n)
.map(|_| {
let mut c = |range: u32| match next() % 40 {
0 => -0.0,
1 => 0.0,
_ => (next() % range) as f32 * 0.5 - (range / 4) as f32,
};
([c(24), c(300), c(150)], 1 + next() % 3000)
})
.collect()
}
#[test]
fn faster_median_cut_and_kmeans_equal_the_originals_bit_for_bit() {
// 40 000 points cross the 256-point radix threshold on the first splits.
for (n, boxes, seed) in [(40_000, 256, 1), (40_000, 37, 2), (3_000, 256, 3), (200, 64, 4), (2, 8, 5)] {
let pts = tie_heavy_points(n, seed);
let new = median_cut(&pts, boxes);
let old = median_cut_reference(&pts, boxes);
assert_eq!(new.len(), old.len());
for (a, b) in new.iter().zip(&old) {
assert_eq!(a.map(f32::to_bits), b.map(f32::to_bits), "median cut, n={n} boxes={boxes}");
}
let (mut a, mut b) = (new, old);
kmeans(&pts, &mut a, 3);
kmeans_reference(&pts, &mut b, 3);
for (x, y) in a.iter().zip(&b) {
assert_eq!(x.map(f32::to_bits), y.map(f32::to_bits), "kmeans, n={n} boxes={boxes}");
}
}
}
#[test]
fn radix_sort_is_the_stable_total_cmp_sort() {
for axis in 0..3 {
let mut pts = tie_heavy_points(5_000, 9 + axis as u64);
// The weight field doubles as an id: equal keys must keep their input order.
for (i, p) in pts.iter_mut().enumerate() {
p.1 = i as u32;
}
pts[10].0[axis] = f32::NAN;
pts[11].0[axis] = -f32::NAN;
pts[12].0[axis] = f32::INFINITY;
pts[13].0[axis] = f32::NEG_INFINITY;
let mut expected = pts.clone();
expected.sort_by(|p, q| p.0[axis].total_cmp(&q.0[axis]));
radix_sort_along(&mut pts, axis);
let bits = |v: &[([f32; 3], u32)]| v.iter().map(|p| (p.0.map(f32::to_bits), p.1)).collect::<Vec<_>>();
assert_eq!(bits(&pts), bits(&expected), "axis {axis}");
}
}
#[test]
fn histogram_counts_opaque_pixels_sorted() {
let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0, 0.49], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]];
assert_eq!(histogram(&px), vec![([0, 0, 0], 1), ([0, 0, 255], 2), ([255, 0, 0], 2)]);
assert!(histogram(&[]).is_empty());
}
#[test]
fn fixed_palettes_have_expected_sizes() {
assert_eq!(web_palette().len(), 216);
+54 -2
View File
@@ -1,4 +1,4 @@
//! Render: Fibers, Lens Flare, Lighting Effects.
//! Render: Clouds, Difference Clouds, Fibers, Lens Flare, Lighting Effects.
use photocraft_geom::Rect;
@@ -18,6 +18,57 @@ fn value_noise(x: f32, y: f32, seed: u32) -> f32 {
top + (bot - top) * sy
}
fn cloud_hash(x: i32, y: i32, seed: u32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
(h & 0x00ff_ffff) as f32 / 0x00ff_ffff as f32
}
/// Smooth value noise, summed over octaves (fBm), in 0..1.
fn clouds_value(x: f32, y: f32, base: f32, seed: u32) -> f32 {
let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / base, 0.0);
for o in 0..8u32 {
let (fx, fy) = (x * freq, y * freq);
let (ix, iy) = (fx.floor() as i32, fy.floor() as i32);
let (tx, ty) = (fx - ix as f32, fy - iy as f32);
let (sx, sy) = (tx * tx * (3.0 - 2.0 * tx), ty * ty * (3.0 - 2.0 * ty));
let s = seed.wrapping_add(o * 7919);
let top = cloud_hash(ix, iy, s) + (cloud_hash(ix + 1, iy, s) - cloud_hash(ix, iy, s)) * sx;
let bot = cloud_hash(ix, iy + 1, s) + (cloud_hash(ix + 1, iy + 1, s) - cloud_hash(ix, iy + 1, s)) * sx;
sum += (top + (bot - top) * sy) * amp;
norm += amp;
amp *= 0.5;
freq *= 2.0;
}
// Stretch the bell-shaped sum so the clouds use the full foreground→background range.
((sum / norm - 0.5) * 2.2 + 0.5).clamp(0.0, 1.0)
}
/// Clouds between the foreground and background colours; Difference Clouds subtracts them from
/// the pixels (alpha kept). The cloud size is a quarter of the bounds' longer side, and the
/// pattern is anchored at the bounds' corner (so it moves with a smart object).
pub(crate) fn clouds(src: &Image, out: Rect, ctx: &Ctx, seed: u32, difference: bool, colours: ([f32; 4], [f32; 4])) -> Vec<f32> {
let n = src.ch;
let (fg, bg) = colours;
let base = (ctx.bounds.width().max(ctx.bounds.height()) as f32 / 4.0).clamp(16.0, 512.0);
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (x, y) = (x - ctx.bounds.x0, y - ctx.bounds.y0);
let t = clouds_value(x as f32, y as f32, base, seed);
let c: [f32; 3] = std::array::from_fn(|k| fg[k] + (bg[k] - fg[k]) * t);
if difference {
let p = rgba(ctx, px);
set_rgba(ctx, px, [(p[0] - c[0]).abs(), (p[1] - c[1]).abs(), (p[2] - c[2]).abs(), p[3]]);
} else {
set_rgba(ctx, px, [c[0], c[1], c[2], 1.0]);
}
}
res
}
/// Fibers: vertical fibrous streaks between the foreground and background
/// colours. Variance sets how quickly the colour varies across fibres (and
/// how short they are); strength stretches each fibre vertically.
@@ -32,8 +83,9 @@ pub(crate) fn fibers(src: &Image, out: Rect, ctx: &Ctx, variance: f32, strength:
let fy = fx / (2.0 + st * 1.5) * (0.5 + var / 32.0);
let mut res = src.crop(out);
for (i, px) in res.chunks_exact_mut(n).enumerate() {
// Anchored at the bounds' corner, so the fibres move with a smart object.
let (x, y) = xy(out, i);
let (xf, yf) = (x as f32, y as f32);
let (xf, yf) = ((x - ctx.bounds.x0) as f32, (y - ctx.bounds.y0) as f32);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
+33 -5
View File
@@ -141,11 +141,15 @@ pub const OBJECT_AT_WORK_PX: usize = 60_000;
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
/// object's outline. A region that grows over most of the canvas is the background: nothing is
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// object's outline. A region covering most of the canvas boundary is treated as background.
/// Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// foreground, the cursor definite foreground, outside the box definite background), the part
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
/// rectangle mode does.
///
/// When the whole-canvas pass finds nothing in a large document (an object only a few working
/// pixels wide blurs into its surroundings), the search is repeated at finer steps in windows of
/// the same working size around the point, down to full resolution.
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
return None;
@@ -154,7 +158,30 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
if !canvas.contains(px, py) {
return None;
}
let step = super::scale_for(canvas, work_px.max(1024));
let work_px = work_px.max(1024);
let mut step = super::scale_for(canvas, work_px);
if let Some(r) = object_in(sampler, canvas, px, py, step) {
return Some(r);
}
// Side (working pixels) of the square windows of the finer passes.
let side = (work_px as f64).sqrt() as usize;
while step > 1 {
step = (step / 4).max(1);
let half = (side.saturating_mul(step) / 2).min(i32::MAX as usize / 4) as i32;
let window = Rect::new(px.saturating_sub(half), py.saturating_sub(half), px.saturating_add(half), py.saturating_add(half)).intersect(&canvas);
if window.width() < 4 || window.height() < 4 {
continue;
}
if let Some(r) = object_in(sampler, window, px, py, step) {
return Some(r);
}
}
None
}
/// One [`object_at`] pass over `canvas` (the document or a window of it) at working step `step`;
/// `(px, py)` lies inside `canvas`.
fn object_in(sampler: &dyn Sampler, canvas: Rect, px: i32, py: i32, step: usize) -> Option<Region> {
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
@@ -166,8 +193,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
let grown = grow_similar(&lab, w, h, sx, sy);
let count = grown.iter().filter(|g| **g).count();
// Background (or a flat image): no single object under the cursor.
if count * 10 > w * h * 6 || count < 4 {
let boundary = grown.iter().enumerate().filter(|(i, on)| **on && (i % w == 0 || i % w + 1 == w || i / w == 0 || i / w + 1 == h)).count();
// shortcut: cropped objects covering most of the frame need a semantic segmenter (#2788).
if boundary * 2 >= 2 * w + 2 * h - 4 || count < 4 {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
+54
View File
@@ -751,6 +751,38 @@ fn object_at_finds_the_disc_under_the_cursor() {
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn object_at_keeps_large_foregrounds_but_not_the_surrounding_background() {
let (w, h) = (400, 300);
for margin in [30, 20, 8] {
let inside = |x: usize, y: usize| x >= margin && x < w - margin && y >= margin && y < h - margin;
let img = RgbImage::from_fn(w, h, |x, y| {
let noise = ((x * 13 + y * 7) % 17) as f32 / 850.0;
if inside(x, y) { [0.8 + noise, 0.2, 0.1] } else { [0.1, 0.2, 0.8 + noise] }
});
let truth: Vec<_> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
let sampler = ImageSampler { img: &img, origin: (-40, 25) };
let canvas = Rect::new(-40, 25, w as i32 - 40, h as i32 + 25);
let region = grabcut::object_at(&sampler, canvas, 160.0, 175.0, grabcut::OBJECT_AT_WORK_PX)
.unwrap_or_else(|| panic!("missing large foreground with margin {margin}"));
let local = Region { bbox: region.bbox.translate(40, -25), mask: region.mask };
assert!(iou(&local, &truth, w, h) > 0.95, "margin {margin}");
assert!(grabcut::object_at(&sampler, canvas, -39.0, 26.0, grabcut::OBJECT_AT_WORK_PX).is_none(), "background margin {margin}");
}
}
#[test]
fn object_at_allows_a_foreground_touching_one_edge_but_rejects_a_flat_image() {
let img = RgbImage::from_fn(400, 300, |x, y| if x < 360 && (20..280).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let sampler = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let region = grabcut::object_at(&sampler, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).unwrap();
assert!(region.at(0, 150) > 0.9 && region.at(350, 150) > 0.9);
assert!(region.at(380, 150) < 0.1 && region.at(200, 5) < 0.1);
let flat = RgbImage::from_fn(400, 300, |_, _| [0.5; 3]);
assert!(grabcut::object_at(&ImageSampler { img: &flat, origin: (0, 0) }, canvas, 200.0, 150.0, grabcut::OBJECT_AT_WORK_PX).is_none());
}
#[test]
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
@@ -762,6 +794,28 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
/// #6576: a 24 px object in a 4000×3000 document is found (a 1000×800 one still is).
#[test]
fn object_at_finds_small_object_in_large_canvas() {
let (w, h) = (4000usize, 3000usize);
let canvas = Rect::new(0, 0, w as i32, h as i32);
for (ox, oy, ow, oh) in [(188usize, 138usize, 24usize, 24usize), (1500, 1100, 1000, 800)] {
let inside = |x: usize, y: usize| x >= ox && x < ox + ow && y >= oy && y < oy + oh;
let img = RgbImage::from_fn(w, h, |x, y| if inside(x, y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
let truth: Vec<bool> = (0..w * h).map(|i| inside(i % w, i / w)).collect();
let s = ImageSampler { img: &img, origin: (0, 0) };
let (cx, cy) = ((ox + ow / 2) as f32, (oy + oh / 2) as f32);
let reg = grabcut::object_at(&s, canvas, cx, cy, grabcut::OBJECT_AT_WORK_PX);
let reg = reg.unwrap_or_else(|| panic!("the {ow}×{oh} object is not found"));
let v = iou(&reg, &truth, w, h);
assert!(v > 0.8, "{ow}×{oh}: IoU {v}");
}
// The flat background around the small object is still nothing.
let img = RgbImage::from_fn(w, h, |x, y| if (188..212).contains(&x) && (138..162).contains(&y) { [0.8, 0.1, 0.1] } else { [0.9, 0.9, 0.9] });
let s = ImageSampler { img: &img, origin: (0, 0) };
assert!(grabcut::object_at(&s, canvas, 400.0, 300.0, grabcut::OBJECT_AT_WORK_PX).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
+24
View File
@@ -1,4 +1,28 @@
use super::*;
#[test]
#[ignore = "release Object Finder timing on a 24 MP document"]
#[allow(clippy::assertions_on_constants)]
fn object_finder_large_foreground_release_timing() {
assert!(!cfg!(debug_assertions), "run with --release");
let (w, h) = (6000, 4000);
let img = RgbImage::from_fn(w, h, |x, y| if (300..5700).contains(&x) && (200..3800).contains(&y) { [0.8, 0.2, 0.1] } else { [0.1, 0.2, 0.8] });
let sampler = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
for (name, x, y) in [("foreground", 3000.0, 2000.0), ("background", 10.0, 10.0)] {
let start = std::time::Instant::now();
let region = grabcut::object_at(&sampler, canvas, x, y, grabcut::OBJECT_AT_WORK_PX);
println!("OBJECT_FINDER {w}x{h} {name}: {:.1} ms", start.elapsed().as_secs_f64() * 1000.0);
if name == "foreground" {
let region = region.unwrap();
assert!(region.at(3000, 2000) > 0.9 && region.at(310, 210) > 0.9);
assert!(region.at(10, 10) < 0.1);
} else {
assert!(region.is_none());
}
}
}
#[test]
#[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"]
#[allow(clippy::assertions_on_constants)]
+173
View File
@@ -0,0 +1,173 @@
//! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an
//! exact oracle: the offset fast path must give the same bits for every mode, depth and layout.
use super::*;
use photocraft_color::ColorMode;
#[allow(clippy::too_many_arguments)]
fn smart_blur_oracle(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
let r = radius.clamp(0.1, 100.0);
let t = threshold.clamp(0.1, 100.0) / 255.0;
let ri = r.ceil() as i32;
let target = match quality {
BlurQuality::Low => 4,
BlurQuality::Medium => 7,
BlurQuality::High => 12,
};
let stride = ((ri + target - 1) / target).max(1);
let offs: Vec<(i32, i32)> = (-ri..=ri)
.step_by(stride as usize)
.flat_map(|dy| (-ri..=ri).step_by(stride as usize).map(move |dx| (dx, dy)))
.filter(|(dx, dy)| ((dx * dx + dy * dy) as f32) <= r * r + 0.5)
.collect();
let white = native(ctx, [1.0, 1.0, 1.0, 1.0]);
let black = native(ctx, [0.0, 0.0, 0.0, 1.0]);
let mut res = src.crop(out);
let mut q = [0.0f32; MAXC];
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let me: [f32; MAXC] = std::array::from_fn(|c| if c < n { px[c] } else { 0.0 });
let differs = |q: &[f32]| (0..cc).any(|c| (q[c] - me[c]).abs() > t);
if mode != SmartBlurMode::Normal {
let mut edge = false;
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1)] {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
edge = true;
break;
}
}
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if edge {
px[..cc].copy_from_slice(&white[..cc]);
} else if mode == SmartBlurMode::EdgeOnly {
px[..cc].copy_from_slice(&black[..cc]);
}
if ctx.alpha {
px[n - 1] = a;
}
continue;
}
acc[..n].fill(0.0);
let mut wsum = 0.0f32;
for &(dx, dy) in &offs {
for (c, v) in q.iter_mut().enumerate().take(n) {
*v = src.get(x + dx, y + dy, c);
}
if differs(&q[..n]) {
continue;
}
let a = if ctx.alpha { q[n - 1] } else { 1.0 };
for c in 0..n {
acc[c] += if ctx.alpha && c < n - 1 { q[c] * a } else { q[c] };
}
wsum += 1.0;
}
if wsum > 0.0 {
for c in 0..n {
px[c] = acc[c] / wsum;
}
unpremul_px(px, ctx.alpha);
}
}
res
}
/// Flat blocks with small noise, so some neighbours fall within the threshold and some don't.
fn image(rect: Rect, n: usize, alpha: bool) -> Image {
let w = rect.width() as usize;
let data = (0..w * rect.height() as usize)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
let block = ((x / 5 + y / 7) % 3) as f32 * 0.3;
(0..n).map(move |c| {
let noise = ((i * 31 + c * 13) % 17) as f32 / 160.0;
if alpha && c + 1 == n { [1.0, 0.0, 0.4, 0.75][(x + y) % 4] } else { block + noise }
})
})
.collect();
Image { rect, ch: n, data }
}
fn exact(a: &[f32], b: &[f32], what: &str) {
assert_eq!(a.len(), b.len(), "{what}");
for (i, (a, b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "{what}: sample {i}: {a} != {b}");
}
}
#[test]
fn offset_fast_path_matches_the_per_channel_oracle() {
let mut layouts: Vec<(ColorMode, usize, bool)> = vec![
(ColorMode::Rgb, 3, false),
(ColorMode::Rgb, 4, true),
(ColorMode::Grayscale, 1, false),
(ColorMode::Grayscale, 2, true),
(ColorMode::Cmyk, 4, false),
(ColorMode::Cmyk, 5, true),
(ColorMode::Lab, 4, true),
];
layouts.extend((1..=MAXC).flat_map(|n| [(ColorMode::Multichannel, n, false), (ColorMode::Multichannel, n, true)]));
let win = Rect::new(-7, -4, 33, 30);
for (mode, n, alpha) in layouts {
let src = image(win, n, alpha);
let ctx = Ctx { bounds: win, mode, alpha };
for radius in [0.1f32, 1.0, 3.0, 12.0, 17.5] {
let ri = f32::ceil(radius) as i32;
// The usual tile (the window less the halo), the whole window, and one sticking out of it.
let inner = Rect::new(win.x0 + ri, win.y0 + ri, win.x1 - ri, win.y1 - ri);
for out in [inner, win, Rect::new(-12, -9, 10, 8), Rect::EMPTY] {
for quality in [BlurQuality::Low, BlurQuality::Medium, BlurQuality::High] {
for sm in [SmartBlurMode::Normal, SmartBlurMode::EdgeOnly, SmartBlurMode::OverlayEdge] {
for threshold in [8.0, 60.0] {
let what = format!("{mode:?} n={n} alpha={alpha} r={radius} {out:?} {quality:?} {sm:?} t={threshold}");
exact(
&smart_blur(&src, out, &ctx, radius, threshold, quality, sm),
&smart_blur_oracle(&src, out, &ctx, radius, threshold, quality, sm),
&what,
);
}
}
}
}
}
}
}
#[test]
fn out_of_window_neighbours_read_as_zero() {
// A one-pixel window: every neighbour is outside and reads as transparent black, so a black
// centre averages with all of them and a white one keeps itself.
let ctx = Ctx { bounds: Rect::new(0, 0, 1, 1), mode: ColorMode::Rgb, alpha: true };
for v in [0.0, 1.0] {
let src = Image { rect: Rect::new(0, 0, 1, 1), ch: 4, data: vec![v, v, v, 1.0] };
let got = smart_blur(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal);
exact(&got, &smart_blur_oracle(&src, src.rect, &ctx, 3.0, 25.0, BlurQuality::High, SmartBlurMode::Normal), "1 px");
}
}
#[test]
fn nan_and_infinite_samples_match_the_oracle() {
// A NaN difference is never over the threshold, so it is averaged in; an infinite one is.
let win = Rect::new(0, 0, 24, 20);
for (n, alpha) in [(3, false), (4, true)] {
let mut src = image(win, n, alpha);
for (i, v) in [(5, f32::NAN), (97, f32::INFINITY), (230, f32::NEG_INFINITY), (311, -0.0)] {
let len = src.data.len();
src.data[(i * n) % len] = v;
}
let ctx = Ctx { bounds: win, mode: ColorMode::Rgb, alpha };
let out = Rect::new(4, 4, 20, 16);
for threshold in [8.0, 100.0] {
exact(
&smart_blur(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
&smart_blur_oracle(&src, out, &ctx, 4.0, threshold, BlurQuality::High, SmartBlurMode::Normal),
&format!("n={n} t={threshold}"),
);
}
}
}
+81 -23
View File
@@ -254,6 +254,21 @@ pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
/// The farthest from the origin (px) [`warp_surface`] places a pixel: each side of
/// [`warp_output_bounds`] is clamped to ±2²⁰.
pub const WARP_REACH: i32 = 1 << 20;
/// Would [`warp_surface`] clip the result of warping `src_rect` by `h`: does a warped corner land
/// past [`WARP_REACH`], whether the result is that large or only placed that far out? A caller
/// refuses such a warp rather than lose the pixels beyond it. Empty and singular inputs have no
/// output to clip.
pub fn warp_output_clipped(src_rect: Rect, h: &Homography) -> bool {
let Some(corners) = warp_corners(src_rect, h) else { return false };
let lim = f64::from(WARP_REACH);
let within = |v: f64| v.is_finite() && v.floor() >= -lim && v.ceil() <= lim;
!corners.iter().all(|&(x, y)| within(x) && within(y))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
@@ -264,11 +279,13 @@ fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
)
}
/// The pixel rectangle over the warped `corners` plus a pixel for filter support, each side
/// clamped to [`WARP_REACH`]; `None` when a corner is not finite.
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
return None;
}
let lim = (1 << 20) as f64;
let lim = f64::from(WARP_REACH);
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
@@ -294,7 +311,7 @@ fn warp(
converted = src.try_convert(fmt)?;
&converted
};
let mut out = Surface::new(fmt);
let out = Surface::new(fmt);
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
return Ok(out);
}
@@ -366,7 +383,7 @@ fn warp(
return Ok(None);
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes.
// Short of 100 %: the result is still pruned and handed back after the last tile.
ctl.progress(0.98 * k as f32 / total as f32);
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
@@ -495,41 +512,40 @@ fn warp(
any = true;
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
for c in 0..a {
// Integer storage clips on write; float colours retain their signed/HDR range.
outp[o + c] = (acc[c] / al) as f32;
// Un-premultiply by the unclamped alpha: bicubic overshoots it past 1 next to
// partly transparent pixels, and dividing by the clamped 1 would lighten the
// colour by the overshoot (#6203). Integer storage clips on write; float
// colours retain their signed/HDR range.
outp[o + c] = (acc[c] / acc[a]) as f32;
}
outp[o + a] = al as f32;
}
}
Ok(any.then_some((*t, outp)))
};
let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec<f32>)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let mut done = Vec::new();
done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?;
let done = std::sync::Mutex::new(done);
// Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32`
// pixels: kept until the last one was done, they cost 16 bytes a pixel of an RGBA result on
// top of the result's own (#7807).
let out = std::sync::Mutex::new(out);
let warp_and_write = |tile: &Rect| -> Result<(), photocraft_raster::AllocationError> {
if let Some((r, v)) = work(tile)? {
out.lock().unwrap_or_else(std::sync::PoisonError::into_inner).try_write_region(r, &v)?;
}
Ok(())
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> {
if let Some(result) = work(&tile)? {
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
}
Ok(())
})?;
tiles.into_par_iter().try_for_each(|tile| warp_and_write(&tile))?;
}
#[cfg(target_arch = "wasm32")]
for tile in tiles {
if let Some(result) = work(&tile)? {
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
}
warp_and_write(&tile)?;
}
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if ctl.cancelled() {
return Ok(out);
}
for (r, v) in done {
out.try_write_region(r, &v)?;
return Ok(Surface::new(fmt));
}
let mut out = out.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
out.prune();
Ok(out)
}
@@ -551,6 +567,8 @@ mod tests {
}
}
/// The unclipped size matches the clipped bounds where nothing is clipped, saturates when it
/// can't be represented, and is `None` for empty or singular inputs.
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
@@ -563,6 +581,20 @@ mod tests {
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
/// [`warp_output_clipped`] says yes exactly when [`warp_output_bounds`] clamps a side (#7807).
#[test]
fn output_clipped_matches_the_clamped_bounds() {
let src = Rect::new(0, 0, 24, 16);
let at = |dx: f64| Homography([1.0, 0.0, dx, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let edge = f64::from(WARP_REACH) - 24.0;
assert!(!warp_output_clipped(src, &at(edge)), "the right edge lands on the reach");
assert_eq!(warp_output_bounds(src, &at(edge)).map(|b| b.x1), Some(WARP_REACH + 1));
assert!(warp_output_clipped(src, &at(edge + 1.0)), "one pixel past it");
assert_eq!(warp_output_bounds(src, &at(edge + 1.0)).map(|b| b.x1), Some(WARP_REACH + 1), "clamped");
assert!(warp_output_clipped(src, &at(-f64::from(WARP_REACH) - 1.0)));
assert!(!warp_output_clipped(Rect::EMPTY, &at(1e12)), "nothing to clip");
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
@@ -808,6 +840,32 @@ mod tests {
assert!((p[0] - 0.2).abs() < 0.01 && p[3] > 0.99, "{p:?}");
}
/// #6203: a bicubic rotation of a sprite whose edge pixels are partly transparent overshoots
/// its alpha past 1. The colour must stay the sprite's, not grow with the overshoot into a
/// light border.
#[test]
fn rotation_keeps_the_colour_of_a_soft_edged_sprite() {
let mut s = rgba();
s.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]);
s.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]);
let (c, k) = (20.0, std::f64::consts::FRAC_1_SQRT_2 * 10.0);
let q = [[c, c - 2.0 * k], [c + 2.0 * k, c], [c, c + 2.0 * k], [c - 2.0 * k, c]];
let h = Homography::rect_to_quad([10.0, 10.0, 30.0, 30.0], q).unwrap();
for interp in [Interp::Bicubic, Interp::Bilinear] {
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
let b = out.content_bounds();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let p = out.pixel(x, y);
if p[3] > 0.0 {
let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max);
assert!(off <= 1.0 / 255.0, "{interp:?} ({x}, {y}): {p:?}");
}
}
}
}
}
#[test]
fn large_downscale_is_prefiltered() {
// A 1px checkerboard shrunk 8× must come out ~50% grey, not aliased black/white.
+106 -12
View File
@@ -345,21 +345,41 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let src_copy = surf.clone();
let fmt = surf.format();
let n = fmt.channels();
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
let mut dabs = 0;
for p in points {
let mc = metric(&hd_inv, lud, lvd, *p);
// Image-space footprint: map a metric circle's bounding points.
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for k in 0..16 {
let a = k as f64 / 16.0 * std::f64::consts::TAU;
let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd);
let (x, y) = hd.apply(u, v);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1));
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for k in 0..16 {
let a = k as f64 / 16.0 * std::f64::consts::TAU;
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
};
let area = footprint(&hd, lud, lvd, mc);
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
continue;
}
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
// decoding it would cost more than the few per-pixel reads it saves.
win.area = footprint(&hs, lus, lvs, [mc[0] + offset[0], mc[1] + offset[1]]).inflate(2);
let dest_px = u64::from(area.width()) * u64::from(area.height());
if win.area.is_empty()
|| win.area.width() > 4096
|| win.area.height() > 4096
|| u64::from(win.area.width()) * u64::from(win.area.height()) > 4 * dest_px
{
win.area = Rect::new(0, 0, 0, 0);
win.data.clear();
} else {
src_copy.read_region_into(win.area, &mut win.data);
}
let mut data = surf.read_region(area);
let w = area.width() as usize;
let mut changed = false;
@@ -376,7 +396,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
// Source pixel at the same metric offset.
let sm = [m[0] + offset[0], m[1] + offset[1]];
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
let s = sample(&src_copy, sx - 0.5, sy - 0.5, n);
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
for c in 0..n {
px[c] += (s[c] - px[c]) * k;
}
@@ -390,11 +410,42 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
dabs
}
fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec<f32> {
/// Pixels of `area` read from the source surface (`read_region` layout).
struct Window {
area: Rect,
data: Vec<f32>,
}
/// Bilinear sample of `s` at (`x`, `y`) into `out` (one pixel), with `corners` (resized to four
/// pixels) as reused scratch. A footprint inside `win` is read from it, any other with one region
/// read from `s`.
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
let n = out.len();
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2));
(0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect()
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
let foot = Rect::new(x0, y0, x1.saturating_add(1), y1.saturating_add(1));
if !win.area.contains_rect(&foot) && foot.width() == 2 && foot.height() == 2 {
// Outside the window (or none): one region read looks the tile up once, where four
// `read_pixel` calls would look it up four times. Same corner order as below.
s.read_region_into(foot, corners);
} else {
corners.resize(4 * n, 0.0);
let w = win.area.width() as usize;
for (k, (px, py)) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)].into_iter().enumerate() {
let dst = &mut corners[k * n..(k + 1) * n];
if win.area.contains(px, py) {
let o = ((py - win.area.y0) as usize * w + (px - win.area.x0) as usize) * n;
dst.copy_from_slice(&win.data[o..o + n]);
} else {
s.read_pixel(px, py, dst);
}
}
}
let r = corners;
for (c, o) in out.iter_mut().enumerate() {
*o = (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty;
}
}
#[cfg(test)]
@@ -487,4 +538,47 @@ mod tests {
assert_eq!(n, 1);
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
}
#[test]
fn sample_at_the_edge_of_i32_does_not_overflow() {
let s = Surface::new(PixelFormat::RGBA8);
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut out) = (Vec::new(), [1.0; 4]);
sample(&s, &win, 3e9, 3e9, &mut corners, &mut out);
assert_eq!(out, [0.0; 4]);
}
#[test]
fn sample_interpolates_across_tile_edges() {
let mut s = Surface::new(PixelFormat::RGBA8);
let (a, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
// Left of x = 256 is red, right is blue; the 2×2 footprint straddles the tile edge.
s.fill_rect(Rect::new(0, 0, 256, 512), &a);
s.fill_rect(Rect::new(256, 0, 512, 512), &b);
let (mut corners, mut out) = (Vec::new(), [0.0; 4]);
let want = [0.75, 0.0, 0.25, 1.0];
// From the surface, from a window holding the footprint, and from one holding half of it.
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260), Rect::new(250, 250, 256, 260)] {
let mut win = Window { area, data: Vec::new() };
s.read_region_into(area, &mut win.data);
sample(&s, &win, 255.25, 255.5, &mut corners, &mut out);
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
}
// Four different corners, one per tile, so both axes of the corner order count.
let mut q = Surface::new(PixelFormat::RGBA8);
for (x, y, px) in [(255, 255, [1.0, 0.0, 0.0, 1.0]), (256, 255, [0.0, 1.0, 0.0, 1.0]), (255, 256, [0.0, 0.0, 1.0, 1.0]), (256, 256, [1.0; 4])] {
q.fill_rect(Rect::new(x, y, x + 1, y + 1), &px);
}
let want = [0.375, 0.25, 0.75, 1.0];
for area in [Rect::new(0, 0, 0, 0), Rect::new(250, 250, 260, 260)] {
let mut win = Window { area, data: Vec::new() };
q.read_region_into(area, &mut win.data);
sample(&q, &win, 255.25, 255.75, &mut corners, &mut out);
assert!(out.iter().zip(want).all(|(o, w)| (o - w).abs() < 1e-6), "{area:?}: {out:?}");
}
// Outside every tile, the default (transparent) pixel.
let win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
sample(&s, &win, -10.5, -10.5, &mut corners, &mut out);
assert_eq!(out, [0.0; 4]);
}
}
+159
View File
@@ -88,3 +88,162 @@ pub(crate) fn ntsc(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
}
res
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilterParams, apply};
use photocraft_color::{PixelFormat, SampleType};
use photocraft_raster::Surface;
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
fn tolerance(depth: SampleType) -> f32 {
match depth {
SampleType::U8 => 1.0 / 255.0,
SampleType::U16 => 1.0 / 65535.0,
SampleType::F32 => 1e-6,
}
}
fn rows(depth: SampleType, bounds: Rect, pixels: &[[f32; 4]]) -> Surface {
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, depth, true));
for (row, px) in pixels.iter().enumerate() {
let y = bounds.y0 + row as i32;
src.fill_rect(Rect::new(bounds.x0, y, bounds.x1, y + 1), px);
}
src
}
#[test]
fn deinterlace_all_field_and_interpolation_choices_use_bounds_relative_rows() {
// Odd document origin: parity is relative to the bounds, not absolute y.
let b = Rect::new(-2, 11, 0, 16);
let pixels = [0.0, 0.2, 0.6, 0.8, 1.0].map(|v| [v, 1.0 - v, v / 2.0, 1.0]);
for depth in DEPTHS {
let src = rows(depth, b, &pixels);
let before = src.read_region(b);
for (eliminate_even, interpolate, neighbours) in [
(false, false, [(1, 1), (1, 1), (1, 1), (3, 3), (3, 3)]),
(false, true, [(1, 1), (1, 1), (1, 3), (3, 3), (3, 3)]),
(true, false, [(0, 0), (0, 0), (2, 2), (2, 2), (4, 4)]),
(true, true, [(0, 0), (0, 2), (2, 2), (2, 4), (4, 4)]),
] {
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None);
for (row, (a, c)) in neighbours.into_iter().enumerate() {
for x in b.x0..b.x1 {
let (pa, pc) = (src.pixel(x, b.y0 + a), src.pixel(x, b.y0 + c));
let actual = out.pixel(x, b.y0 + row as i32);
for channel in 0..4 {
let expected = (pa[channel] + pc[channel]) / 2.0;
assert!(
(actual[channel] - expected).abs() <= tolerance(depth),
"{depth:?}, even={eliminate_even}, interp={interpolate}, row={row}: {actual:?}"
);
}
}
}
}
assert_eq!(src.read_region(b), before);
}
}
#[test]
fn deinterlace_interpolation_weights_colour_by_alpha() {
let b = Rect::new(0, 0, 1, 3);
for depth in DEPTHS {
for [a, c] in [[0.25, 0.75], [1.0, 0.0], [0.0, 0.0]] {
let src = rows(depth, b, &[[1.0, 0.0, 0.0, a], [0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, c]]);
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even: true, interpolate: true }, b, b, None);
let (a, c) = (src.pixel(0, 0)[3], src.pixel(0, 2)[3]);
let total = a + c;
let expected = if total > 0.0 { [a / total, 0.0, c / total, total / 2.0] } else { [0.0; 4] };
let actual = out.pixel(0, 1);
for (v, want) in actual.iter().zip(expected) {
assert!((v - want).abs() <= tolerance(depth), "{depth:?}: {actual:?} != {expected:?}");
}
assert_eq!(out.pixel(0, 0), src.pixel(0, 0));
assert_eq!(out.pixel(0, 2), src.pixel(0, 2));
}
}
}
#[test]
fn deinterlace_one_and_two_row_images_without_alpha_handle_missing_neighbours() {
for depth in DEPTHS {
for height in [1, 2] {
let b = Rect::new(0, -3, 1, -3 + height);
let mut src = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
src.fill_rect(b, &[0.2]);
if height == 2 {
src.fill_rect(Rect::new(0, -2, 1, -1), &[0.8]);
}
for eliminate_even in [false, true] {
for interpolate in [false, true] {
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, b, b, None);
let kept_y = if height == 2 && !eliminate_even { -2 } else { -3 };
for y in b.y0..b.y1 {
assert_eq!(out.pixel(0, y), src.pixel(0, kept_y), "{depth:?}, height={height}, even={eliminate_even}, interp={interpolate}");
}
}
}
}
}
}
#[test]
fn deinterlace_leaves_pixels_outside_reference_bounds_unchanged() {
let area = Rect::new(0, 0, 1, 5);
let b = Rect::new(0, 1, 1, 4);
let src = rows(SampleType::F32, area, &[[0.1; 4], [0.2; 4], [0.4; 4], [0.8; 4], [1.0; 4]]);
for eliminate_even in [false, true] {
for interpolate in [false, true] {
let out = apply(&src, &FilterParams::DeInterlace { eliminate_even, interpolate }, area, b, None);
assert_eq!(out.pixel(0, 0), src.pixel(0, 0));
assert_eq!(out.pixel(0, 4), src.pixel(0, 4));
}
}
}
#[test]
fn ntsc_safe_colours_and_alpha_are_bit_exact_at_all_depths() {
let b = Rect::new(0, 0, 1, 5);
for depth in DEPTHS {
let src = rows(depth, b, &[[0.0, 0.0, 0.0, 0.0], [0.5, 0.5, 0.5, 0.25], [1.0, 1.0, 1.0, 0.75], [0.2, 0.3, 0.4, 1.0], [1.0, 0.0, 1.0, 1.0]]);
let out = apply(&src, &FilterParams::NtscColors, b, b, None);
assert_eq!(out.read_region(b), src.read_region(b), "{depth:?}");
}
}
fn luma_chroma(p: &[f32]) -> (f32, f32) {
let y = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
let i = 0.595716 * p[0] - 0.274453 * p[1] - 0.321263 * p[2];
let q = 0.211456 * p[0] - 0.522591 * p[1] + 0.311135 * p[2];
(y, i.hypot(q))
}
#[test]
fn ntsc_saturated_colours_keep_luma_alpha_and_meet_composite_limits() {
// Independent signal checks: -20..120 IRE relative to the 7.5..100 IRE luma range.
let (lo, hi) = ((-20.0 - 7.5) / 92.5, (120.0 - 7.5) / 92.5);
let colours = [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.25], [1.0, 1.0, 0.0, 0.5], [0.0, 1.0, 1.0, 0.75]];
let b = Rect::new(0, 0, 1, colours.len() as i32);
for depth in DEPTHS {
let src = rows(depth, b, &colours);
let out = apply(&src, &FilterParams::NtscColors, b, b, None);
// The rounded YIQ inverse contributes < 0.0003; integer writes add one sample step.
let eps = 0.0003 + 2.0 * tolerance(depth);
for y in b.y0..b.y1 {
let (before, after) = (src.pixel(0, y), out.pixel(0, y));
let (y0, c0) = luma_chroma(&before);
let (y1, c1) = luma_chroma(&after);
assert!(y0 - c0 < lo || y0 + c0 > hi, "fixture must require correction");
assert!(c1 < c0, "chroma not reduced: {depth:?} {after:?}");
assert!((y1 - y0).abs() <= eps, "luma changed: {depth:?} {before:?} -> {after:?}");
assert!(y1 - c1 >= lo - eps && y1 + c1 <= hi + eps, "signal out of gamut: {depth:?} {after:?}");
assert!(after[..3].iter().all(|v| v.is_finite() && *v >= 0.0 && *v <= 1.0));
assert_eq!(after[3], before[3], "alpha changed: {depth:?}");
}
}
}
}
+76 -3
View File
@@ -15,7 +15,7 @@
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Surface, Tile};
use photocraft_raster::{Interrupt, Surface, Tile};
use std::sync::Arc;
use crate::transform::{Interp, bicubic};
@@ -75,6 +75,16 @@ pub fn place_source_projective(
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
/// added; pixels outside the warped area are transparent.
pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp) -> Surface {
mesh(src, src_rect, f, interp, &Interrupt::NONE)
}
/// [`warp_mesh_surface`] reporting progress per destination tile; `None` once `ctl` is cancelled.
pub fn warp_mesh_surface_with(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Option<Surface> {
let out = mesh(src, src_rect, f, interp, ctl);
(!ctl.cancelled()).then_some(out)
}
fn mesh(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync), interp: Interp, ctl: &Interrupt) -> Surface {
if src_rect.is_empty() {
let fmt = src.format();
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
@@ -104,7 +114,7 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
tris.push([a, a + w1 + 1, a + w1]);
}
}
warp_triangles(src, src_rect, &verts, &tris, interp)
triangles(src, src_rect, &verts, &tris, interp, ctl)
}
/// Probe lines per axis [`mapped_lengths`] follows through the map.
@@ -149,6 +159,12 @@ fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
/// order is the depth order). Output has `src`'s format with alpha added.
pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp) -> Surface {
triangles(src, src_rect, verts, tris, interp, &Interrupt::NONE)
}
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
/// the remaining tiles; the caller discards the result.
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
let mut fmt = src.format();
let converted;
let src = if fmt.alpha {
@@ -208,7 +224,16 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let n = fmt.channels();
let a = n - 1;
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
if ctl.cancelled() {
return None;
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are putting the tiles in place.
ctl.progress(0.98 * k as f32 / total as f32);
if tris.is_empty() {
return None;
}
@@ -406,7 +431,8 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
any = true;
let b = o * n;
for c in 0..a {
outp[b + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
// The unclamped alpha, so a bicubic overshoot past 1 doesn't lighten the colour (#6203).
outp[b + c] = (acc[c] / acc[a]).clamp(0.0, 1.0) as f32;
}
outp[b + a] = al as f32;
}
@@ -430,6 +456,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
if ctl.cancelled() {
return out;
}
out.put_tiles(done);
out
}
@@ -503,6 +532,28 @@ mod tests {
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
}
#[test]
fn the_interruptible_mesh_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
let r = s.content_bounds();
let w = Warp::preset(WarpStyle::Arc, 30.0, [0.0, 0.0, 600.0, 400.0]);
let map = |x: f64, y: f64| w.map(x, y);
let reported = std::sync::Mutex::new(0.0f32);
let progress = |p: f32| {
let mut m = reported.lock().unwrap();
*m = m.max(p);
};
let never = || false;
let out = warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::new(&never, &progress)).expect("not cancelled");
let want = warp_mesh_surface(&s, r, &map, Interp::Bicubic);
assert_eq!(out.content_bounds(), want.content_bounds());
assert_eq!(out.read_region(want.content_bounds()), want.read_region(want.content_bounds()));
assert!(*reported.lock().unwrap() > 0.9);
let always = || true;
assert!(warp_mesh_surface_with(&s, r, &map, Interp::Bicubic, &Interrupt::cancel_only(&always)).is_none());
}
#[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
@@ -538,6 +589,28 @@ mod tests {
}
}
#[test]
fn a_rotated_soft_edged_sprite_keeps_its_colour() {
// #6203, as in Free Transform: the bicubic alpha overshoots past 1 next to the half
// transparent edge, and must not lighten the colour.
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
src.fill_rect(Rect::new(10, 10, 30, 30), &[0.8, 0.6, 0.4, 0.5]);
src.fill_rect(Rect::new(11, 11, 29, 29), &[0.8, 0.6, 0.4, 1.0]);
let k = std::f64::consts::FRAC_1_SQRT_2;
let rotate = |x: f64, y: f64| (20.0 + (x - 20.0) * k - (y - 20.0) * k, 20.0 + (x - 20.0) * k + (y - 20.0) * k);
let out = warp_mesh_surface(&src, src.content_bounds(), &rotate, Interp::Bicubic);
let b = out.content_bounds();
for y in b.y0..b.y1 {
for x in b.x0..b.x1 {
let p = out.pixel(x, y);
if p[3] > 0.0 {
let off = (0..3).map(|i| (p[i] - [0.8, 0.6, 0.4][i]).abs()).fold(0.0f32, f32::max);
assert!(off <= 1.0 / 255.0, "({x}, {y}): {p:?}");
}
}
}
}
#[test]
fn translation_and_known_arc_points() {
let src = sample(SampleType::U8);
@@ -0,0 +1,108 @@
//! Liquify source reads must depend on the mapping, not the output tile phase.
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// Keeps the document size fixed while moving its edges relative to global tiles.
fn bounds(shift: i32) -> Rect {
Rect::new(shift, 0, shift + 1024, 512)
}
/// Builds translated copies of RGB ramps and alternating columns so missing
/// interpolation taps change the sampled colors.
fn source(sample: SampleType, shift: i32) -> Surface {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, false));
let mut row = vec![0.0; 1024 * 3];
for y in 0..512 {
for (x, pixel) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() {
*pixel = [x as f32 / 1024.0, (x % 2) as f32, y as f32 / 512.0];
}
surface.write_region(Rect::new(shift, y, shift + 1024, y + 1), &row);
}
surface
}
/// Creates matching brush paths in document-relative coordinates, optionally
/// beside a frozen region that gives the displacement field a sharp boundary.
fn strokes(shift: i32, masked: bool) -> Vec<LiquifyStroke> {
let offset = f64::from(shift);
let mut strokes = Vec::new();
if masked {
let mut freeze = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
freeze.amount = Some(1.0);
freeze.points = vec![vec![offset, 0.0], vec![offset + 508.0, 0.0], vec![offset + 508.0, 512.0], vec![offset, 512.0]];
strokes.push(freeze);
}
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 512.0);
warp.density = 100.0;
warp.points = vec![vec![offset + 512.0, 256.5], vec![offset + 499.2, 256.5]];
strokes.extend(std::iter::repeat_n(warp, 8));
for stroke in &strokes {
stroke.validate().expect("the fixture uses supported brush parameters");
}
strokes
}
/// Checks that a half-tile translation preserves editable output across sample
/// depths and mesh sizes when image content and brush paths move together.
fn check_tile_translation(masked: bool) {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let original = source(sample, 0);
let translated = source(sample, 128);
for cell in [2.0, 4.0] {
let field = LiquifyField::from_strokes(bounds(0), cell, &strokes(0, masked));
let shifted_field = LiquifyField::from_strokes(bounds(128), cell, &strokes(128, masked));
if masked {
assert!(field.d == shifted_field.d, "translation must preserve the nodal displacement values");
}
// The reported pixels lie entirely on the editable side of the mask.
assert_eq!(field.freeze_at(509.5, 220.5), 0.0);
assert_eq!(shifted_field.freeze_at(637.5, 220.5), 0.0);
let output = apply_liquify(&original, &field);
let shifted_output = apply_liquify(&translated, &shifted_field);
assert_ne!(output.pixel(509, 220), original.pixel(509, 220), "the fixture must actually warp the inspected strip");
let mut worst = 0.0f32;
// Translation changes which output tile contains this strip, while
// preserving the relative source image, brush path and document edges.
for y in 220..290 {
for x in 509..512 {
let a = output.pixel(x, y);
let b = shifted_output.pixel(x + 128, y);
for (a, b) in a.iter().zip(&b) {
worst = worst.max((a - b).abs());
}
}
}
// Coordinate translation can perturb the last few F32 arithmetic bits.
// This is far smaller than the clipped source-stencil error.
let tolerance = if sample == SampleType::F32 { 2e-6 } else { 0.0 };
assert!(worst <= tolerance, "{sample:?}, meshSize {cell}, masked={masked}: moving the document by half a tile changed output by {worst}");
}
}
}
#[test]
fn cubic_source_footprint_is_independent_of_output_tile_phase() {
check_tile_translation(true);
}
#[test]
fn ordinary_unmasked_warp_is_independent_of_output_tile_phase() {
check_tile_translation(false);
}
#[test]
fn source_reads_still_clamp_at_the_document_edge() {
let mut warp = LiquifyStroke::new(LiquifyTool::ForwardWarp, 256.0);
warp.density = 100.0;
warp.points = vec![vec![1008.0, 256.5], vec![944.0, 256.5]];
warp.validate().expect("the edge fixture uses supported brush parameters");
let field = LiquifyField::from_strokes(bounds(0), 4.0, &[warp]);
assert!(field.sample(1023.5, 256.5)[0] > 4.0, "the fixture must sample beyond the right document edge");
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let original = source(sample, 0);
let output = apply_liquify(&original, &field);
assert_eq!(output.pixel(1023, 256), original.pixel(1023, 256), "{sample:?}: an out-of-document source read must repeat the true document edge");
}
}
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, linked into one test binary.
mod liquify_source_footprint;
mod retouch_blur_exact;
mod warp_clipped;
File renamed without changes.
+2 -1
View File
@@ -10,7 +10,6 @@ rust-version.workspace = true
workspace = true
[dependencies]
photocraft-geom = { workspace = true }
photocraft-doc = { workspace = true }
photocraft-compose = { workspace = true }
photocraft-codecs = { workspace = true }
@@ -28,6 +27,8 @@ getrandom = { workspace = true }
cap-std = "4"
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
+2
View File
@@ -100,6 +100,8 @@ impl Headless {
files::open_bytes(name, &bytes)?
}
};
// Preferences › Raw Defaults › Resolution, as the app's File › Open applies it.
file_cmds::raw_defaults(&self.session, &o.warnings, &mut o.document);
let path = match file_cmds::template_name(&self.session, requested) {
Some(untitled) => {
o.document.name = untitled;
+13
View File
@@ -351,6 +351,19 @@ mod tests {
Mutex::new(Headless::trusted_local())
}
#[test]
fn document_activation_reply_survives_engine_rpc() {
let h = session();
let mut g = h.lock().unwrap();
for _ in 0..2 {
g.handle("engine.execute", json!({"command": "file.new", "params": {"width": 4, "height": 4}})).unwrap();
}
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"offset": -1}})).unwrap();
assert_eq!(reply, json!({"document": 0}));
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"document": 1}})).unwrap();
assert_eq!(reply, json!({"document": 1}));
}
#[test]
fn execute_reports_invalid_filter_parameters_without_changing_history() {
let h = session();
+1 -1
View File
@@ -1197,7 +1197,7 @@ mod tests {
break;
}
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
std::thread::sleep(std::time::Duration::from_millis(5));
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
}
+51 -8
View File
@@ -7,7 +7,7 @@ use crate::security::MAX_REQUEST_BYTES;
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
/// executable at EOF after an oversized suffix is rejected.
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<Vec<u8>>> {
let mut bytes = Vec::new();
loop {
let available = source.fill_buf().await?;
@@ -15,7 +15,7 @@ async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>
if bytes.is_empty() {
return Ok(None);
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
return Ok(Some(bytes));
}
let newline = available.iter().position(|&byte| byte == b'\n');
let length = newline.map_or(available.len(), |at| at + 1);
@@ -29,7 +29,7 @@ async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
return Ok(Some(bytes));
}
}
}
@@ -48,11 +48,16 @@ impl PhotocraftMcp {
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input);
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
if line.trim().is_empty() {
if std::str::from_utf8(&line).is_ok_and(|text| text.trim().is_empty()) {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(_) => to_service.write_all(format!("{line}\n").as_bytes()).await?,
// Bytes that are not UTF-8 are malformed JSON too: they get the -32700 reply
// like any other bad line instead of ending the session.
match serde_json::from_slice::<Value>(&line) {
Ok(_) => {
to_service.write_all(&line).await?;
to_service.write_all(b"\n").await?;
}
Err(e) => {
let reply = json!({"jsonrpc":"2.0", "id":null, "error":{"code":-32700, "message":format!("parse error: {e}")}});
if errors.send(reply.to_string()).await.is_err() {
@@ -99,8 +104,8 @@ mod tests {
#[tokio::test]
async fn bounded_line_accepts_exact_limit_and_final_line() {
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"1234"[..]));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some(&b"last!"[..]));
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
@@ -143,4 +148,42 @@ mod tests {
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 0, "the oversized tool call must not run");
}
async fn next_reply(replies: &mut tokio::io::Lines<BufReader<tokio::io::DuplexStream>>) -> Value {
let line = tokio::time::timeout(std::time::Duration::from_secs(5), replies.next_line()).await.expect("a reply in time").unwrap().expect("a reply line");
serde_json::from_str(&line).unwrap()
}
#[tokio::test]
async fn a_line_that_is_not_utf8_gets_a_parse_error_and_the_session_keeps_serving() {
let (mut client, input) = tokio::io::duplex(1 << 16);
let (output, replies) = tokio::io::duplex(1 << 16);
let server = PhotocraftMcp::headless();
let observer = server.clone();
let session = tokio::spawn(server.serve_io(input, output));
let mut replies = BufReader::new(replies).lines();
let initialize = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "utf8-test", "version": "1"}
}});
client.write_all(format!("{initialize}\n").as_bytes()).await.unwrap();
assert_eq!(next_reply(&mut replies).await["id"], 1);
client.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n").await.unwrap();
client.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\xff\xfe}\n").await.unwrap();
let error = next_reply(&mut replies).await;
assert_eq!(error["error"]["code"], -32700, "{error}");
assert_eq!(error["id"], Value::Null, "{error}");
let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_new", "arguments": {"width": 8, "height": 8}}});
client.write_all(format!("{call}\n").as_bytes()).await.unwrap();
let reply = next_reply(&mut replies).await;
assert_eq!(reply["id"], 2, "{reply}");
assert_ne!(reply["result"]["isError"], true, "{reply}");
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 1, "the call after the bad line must run");
drop(client);
session.await.unwrap().unwrap();
}
}
+37 -4
View File
@@ -340,6 +340,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
}
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
// New Document's Color Profile (#321) can name an .icc file.
"file.new" => &["profile"],
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
_ => &[],
};
@@ -356,6 +358,13 @@ fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") {
return true;
}
// New Document skips exact `working` and `none`; the other working aliases resolve
// the configured working space, which may be an .icc path.
if id == "file.new" {
return params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
matches!(profile, "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
});
}
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
&& params.get("profile").and_then(Value::as_str).is_some_and(|profile| {
matches!(profile, "working" | "default" | "working-cmyk" | "workingCmyk" | "working-rgb" | "workingRgb" | "working-gray" | "workingGray")
@@ -374,18 +383,27 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
fn preference_uses_ambient_filesystem(path: &str) -> bool {
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
// judge the first non-empty one; no segment at all is the whole-preferences update.
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
let mut segments = path.split('.').filter(|segment| !segment.is_empty());
let Some(section) = segments.next() else { return true };
// File Handling's LUT folder (#6558) is listed from disk; the rest of that section is not.
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
|| (section == "fileHandling" && segments.next().is_none_or(|key| key == "lutFolder"))
}
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"actions.load"
| "brush.presets.importAbr"
| "brush.presets.importImage"
| "gradient.presets.importGrd"
| "plugin.install"
| "swatches.import"
| "layer.layerStyle.contours.load" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
"actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
@@ -759,6 +777,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "adobe-rgb-compat"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "none"})).is_ok());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "default"})).is_err());
assert!(authorize_engine_command("file.new", &serde_json::json!({"profile": "working-cmyk"})).is_err());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
@@ -821,6 +845,8 @@ mod tests {
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})),
("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
@@ -833,6 +859,8 @@ mod tests {
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
@@ -847,6 +875,8 @@ mod tests {
"historyLog.filePath",
".historyLog.filePath",
"plugIns.additionalPluginsFolder",
"fileHandling.lutFolder",
"fileHandling",
"scratchDisks.disks",
".",
"..historyLog.filePath",
@@ -857,6 +887,7 @@ mod tests {
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
);
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "fileHandling.autosave", "value": false})).is_ok());
}
#[test]
@@ -887,7 +918,9 @@ mod tests {
}
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
if matches!(id, "prefs.get" | "prefs.reset") {
// These commands return settings or installed-profile paths; they do not accept a host
// filesystem path as an automation input.
if matches!(id, "prefs.get" | "prefs.reset" | "edit.installedProfiles") {
return false;
}
// These registry descriptions refer to document vector paths, not host files.
@@ -0,0 +1,184 @@
//! M11 acceptance, part two: ten more scripted edit tasks (21–30) over MCP (roadmap M11 DoD).
//!
//! Same contract as `agent_tasks.rs`: each task uses only MCP tools (`doc_*`, `command_run`) the
//! way an agent would, and checks the outcome through `doc_inspect`, `document.pixel` or the
//! exported files, never engine internals.
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
use rmcp::service::RunningService;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use serde_json::{Value, json};
#[derive(Clone, Default)]
struct Client;
impl ClientHandler for Client {
fn get_info(&self) -> ClientConfig {
ClientConfig::default()
}
}
type Conn = RunningService<RoleClient, Client>;
type Server = tokio::task::JoinHandle<()>;
async fn connect(root: &std::path::Path) -> (Conn, Server) {
let (s, c) = tokio::io::duplex(1 << 20);
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
let server = tokio::spawn(async move {
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
let _ = running.waiting().await;
}
});
(Client.serve(c).await.expect("client init"), server)
}
async fn shutdown(c: Conn, server: Server) {
c.cancel().await.unwrap();
server.await.unwrap();
}
fn text(r: &CallToolResult) -> String {
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
}
/// Call a tool and parse its JSON result, failing the test on a tool error.
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
let mut p = CallToolRequestParams::new(name.to_owned());
if let Value::Object(m) = args {
p = p.with_arguments(m);
}
let r = c.call_tool(p).await.expect("call_tool transport");
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
}
async fn run(c: &Conn, id: &str, params: Value) -> Value {
tool(c, "command_run", json!({"id": id, "params": params})).await
}
async fn inspect(c: &Conn) -> Value {
tool(c, "doc_inspect", json!({})).await
}
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
}
fn top_layer(doc: &Value) -> &Value {
&doc["layers"][0]
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn close(a: &[f64], b: &[f64], tol: f64) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
/// The next ten acceptance tasks (21–30): fill layers, adjustment layers with masks, grouping,
/// merging, gradients of edits, channel round trips, JPEG export and a native save round trip
/// that preserves a group.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_ten_extended_tasks() {
let dir = tmp("tasks3");
let (c, server) = connect(&dir).await;
// 21. Paint a red background and cover it with a solid-colour fill layer.
tool(&c, "doc_new", json!({"width": 64, "height": 64, "background": "white"})).await;
run(&c, "select.all", json!({})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.newFillLayer.solidColor", json!({"color": "#00ffff"})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Fill", "task 21");
let cyan = pixel(&c, 32, 32).await;
assert!(cyan[1] > 0.99 && cyan[2] > 0.99 && cyan[0] < 0.01, "task 21: the fill covers red {cyan:?}");
// 22. An Invert adjustment flips it to red; deleting the adjustment restores cyan.
run(&c, "layer.newAdjustmentLayer.invert", json!({})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 22");
let inverted = pixel(&c, 32, 32).await;
assert!(inverted[0] > 0.99 && inverted[1] < 0.01 && inverted[2] < 0.01, "task 22: cyan inverts to red {inverted:?}");
run(&c, "layer.delete", json!({})).await;
assert!(close(&pixel(&c, 32, 32).await, &cyan, 1e-4), "task 22: deleting the adjustment restores cyan");
// 23. Hide a rectangular selection behind the layer's mask.
run(&c, "layer.delete", json!({})).await; // drop the fill so the red background shows
run(&c, "select.rect", json!({"x": 16, "y": 16, "width": 32, "height": 32})).await;
run(&c, "layer.layerMask.hideSelection", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["hasMask"], true, "task 23");
let hidden = pixel(&c, 32, 32).await;
let kept = pixel(&c, 4, 4).await;
assert!(hidden[3] < 0.01, "task 23: the selection is masked away {hidden:?}");
assert!(kept[3] > 0.99 && kept[0] > 0.99, "task 23: outside keeps the red {kept:?}");
run(&c, "layer.layerMask.delete", json!({})).await;
// 24. Group every layer; the pixels are unchanged.
let before = pixel(&c, 32, 32).await;
run(&c, "select.allLayers", json!({})).await;
run(&c, "layer.groupLayers", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Group", "task 24: {doc}");
assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 24: grouping is visual only");
// 25. Flatten the grouped document to one layer.
run(&c, "layer.flattenImage", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 25");
// 26. Duplicate the layer and delete the copy.
run(&c, "layer.duplicate", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 26");
run(&c, "layer.delete", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 26");
// 27. A Curves adjustment darkens the red; deleting it restores the original.
run(&c, "layer.newAdjustmentLayer.curves", json!({"points": [[0, 0], [128, 32], [255, 128]]})).await;
let dark = pixel(&c, 32, 32).await;
assert!(dark[0] < 0.9, "task 27: the red channel deepens {dark:?}");
run(&c, "layer.delete", json!({})).await;
assert!(close(&pixel(&c, 32, 32).await, &before, 1e-4), "task 27: deleting restores the red");
// 28. Hue/Saturation at -100 desaturates the red to gray.
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"saturation": -100})).await;
let gray = pixel(&c, 32, 32).await;
assert!((gray[0] - gray[1]).abs() < 0.05 && (gray[1] - gray[2]).abs() < 0.05, "task 28: saturated red goes gray {gray:?}");
run(&c, "layer.delete", json!({})).await;
// 29. Save a selection to a channel, load it back and paint through it.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 24, "height": 24})).await;
run(&c, "select.saveSelection", json!({"name": "Box"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "select.loadSelection", json!({"channel": "Box"})).await;
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
let painted = pixel(&c, 32, 32).await;
let outside = pixel(&c, 4, 4).await;
assert!(painted[2] > 0.99 && painted[0] < 0.01, "task 29: the loaded channel fills blue {painted:?}");
assert!(outside[0] > 0.99 && outside[2] < 0.01, "task 29: outside keeps red {outside:?}");
run(&c, "select.deselect", json!({})).await;
// 30. Save natively with a group, reopen and find the group and its content intact.
run(&c, "layer.new.layer", json!({"name": "Badge"})).await;
run(&c, "select.rect", json!({"x": 40, "y": 40, "width": 16, "height": 16})).await;
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "select.allLayers", json!({})).await;
run(&c, "layer.groupLayers", json!({"name": "Set"})).await;
tool(&c, "doc_save", json!({"path": "grouped.pcraft"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "grouped.pcraft"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Group", "task 30: {doc}");
assert_eq!(top_layer(&doc)["name"], "Set", "task 30: {doc}");
let green = pixel(&c, 48, 48).await;
assert!(green[1] > 0.99 && green[0] < 0.01, "task 30: the badge inside the group survives {green:?}");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
File renamed without changes.
File renamed without changes.
+16
View File
@@ -0,0 +1,16 @@
//! The crate's integration tests, linked into one test binary.
mod agent_tasks;
mod agent_tasks_extended;
mod agent_tasks_more;
mod bridge;
mod inactive_save;
mod krita;
mod layered_save_dirty;
mod mcp;
mod mcp_wire;
mod missing_font_warnings;
mod native_save_name;
mod openraster;
mod raw_defaults;
mod smart_filter_authorization;
@@ -774,11 +774,12 @@ async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_au
let client = connect(PhotocraftMcp::headless()).await;
let run = async |id: &str, params: Value| json_of(&call(&client, "command_run", json!({"id": id, "params": params})).await);
json_of(&call(&client, "doc_new", json!({"width": 16, "height": 12, "background": "white"})).await);
run("layer.smartObjects.convertToSmartObject", json!({})).await;
let outer = run("layer.smartObjects.convertToSmartObject", json!({})).await["layer"].clone();
// An explicit target works even with a regular pixel layer selected.
run("layer.new.layer", json!({"name": "Other layer"})).await;
assert_eq!(run("layer.smartObjects.editContents", json!({"layer": outer})).await["document"], 1);
// A smart object cannot be converted again, so nest one inside the contents instead.
run("layer.smartObjects.convertToSmartObject", json!({})).await;
assert_eq!(run("layer.smartObjects.editContents", json!({})).await["document"], 2);
run("layer.setProps", json!({"name": "Edited photo", "opacity": 0.5})).await;
assert_eq!(run("layer.smartObjects.saveContents", json!({})).await["updated"], true);
File renamed without changes.
Loaded 100 of 870 files, more files were not shown because too many files have changed in this diff. Show more