Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 86953e0ebf security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:07:12 -04:00
skylar taylor-barrick 6d8cbac7dc Merge pull request #2945 from diegochagas/feat/xcf-read
Open GIMP XCF files: layers, groups, masks, blend modes, channels and selection
2026-10-21 14:35:32 -06:00
skylar taylor-barrick 18e2d45420 Merge pull request #6318 from robertoffmoura/fix/floating-selection-move-lag
Fix lag when dragging a floating selection with the Move tool
2026-10-21 14:25:55 -06:00
Dylan M. Taylor 87fa191818 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:47 -04:00
Diego Chagas ca18689304 Open GIMP XCF files: layers, groups, masks, modes, channels and selection (#1076)
PhotoCraft reads GIMP's .xcf (and .xcf.gz) files, read only, through a new
reader in photocraft-io written from the public XCF specification: versions
0 to 26, uncompressed, RLE and zlib tiles, 8/16/32-bit integer and
16/32/64-bit float precisions (linear precisions get a linear profile),
pixel layers at any position, groups by item path with pass-through,
layer masks applied or disabled, opacity, visibility, locks, colour tags,
the 2.10 and legacy blend modes mapped to Photoshop's (modes without an
equivalent open as Normal with a note), the ICC profile, resolution,
guides, the comment, extra channels and the saved selection; indexed
images open as RGB. Text, vector and link layers open as their pixels;
layer effects, paths and floating selections are reported.

Fixtures are written by GIMP 3.2 itself (scripts/xcf_fixtures.py), each
with GIMP's merged rendering as a PNG. In gamma-encoded blend and
composite spaces our composite matches GIMP within 0.5/255; GIMP's
linear-light default, its Clip to backdrop and its clamp-after-alpha
for unbounded modes are documented and measured in docs/xcf.md.
2026-10-21 16:57:22 -03:00
Dylan M. Taylor 8e518d8169 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:04 -04:00
Brandon ThomasandClaude Opus 5.5 6a2d027ee2 Release: version 0.12.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:59:16 -04:00
Roberto Moura f4ea3139d7 Keep selection snap targets when dragging a guide with the Move tool 2026-10-21 16:37:54 +00:00
aiongg 68348dcafc Merge pull request #8271 from aiongg/integration-8
Integration batch: 86 PRs on main
2026-10-21 11:02:54 -04:00
Roberto Moura 8352b26c1a Fix lag when dragging a floating selection with the Move tool
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
  with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
  sharing interior Arc<Tile> allocations in O(1) and activating the
  solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
  content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
  opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
  identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
  prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
  move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
  tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
  canvas so navigator_gpu is not evicted into a synchronous full-document
  CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
  add a direct read_rgba_into fast path for unmasked Normal layers over
  an empty backdrop.
2026-10-21 13:21:10 +00:00
Dylan M. Taylor 04d0212c7a Merge pull request #6448 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:26 -04:00
aiongg abd0582add Integration: the Type units test makes its type after the resolution change, for #7125
#7125 keeps existing type's pixel size across Image Size, so the #6491 test's 12 pt layer, made before the change to 144 ppi, became 6 pt. The test now creates its layer at 144 ppi; its assertions are unchanged.
2026-10-21 05:04:04 +00:00
aiongg 194dfcf0ec Integration: Clear History resets the History Brush source and Undo brings it back, for #3534 and #3536 2026-10-21 05:04:04 +00:00
aiongg c5176efd93 Integration: a scale beyond f32 range is refused, not clamped, for #7048
Main's 3f216a0cd1 rejects numeric overflow before document edits; #7048's test predates it and expected 1e39 to clamp to 150 %.
2026-10-21 05:04:04 +00:00
aiongg b1e408d28f Merge the smart-filter track (#7066 #6398 #6397 #6297 #6299 #6301 #6303 #6315 #6305 #6317)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 05:04:04 +00:00
aiongg a2c56f960f Integration: gaps.md preferences count from the regenerated scorecard, for #6208 and #6211 2026-10-21 05:04:04 +00:00
aiongg 8e04e6f21e Integration: regenerate docs/scorecard.md for the batch 2026-10-21 05:04:04 +00:00
aiongg cb8bea1636 Merge PR #7044: Conditional Mode Change: "gray" as a single source mode matches Grayscale (#7042)
# Conflicts:
#	crates/engine/src/file_cmds/tests.rs
2026-10-21 05:04:04 +00:00
aiongg 1d567a115f Merge PR #7015: Actions: deleting an action removes its function key (#7014)
# Conflicts:
#	crates/engine/src/actions_cmds.rs
2026-10-21 05:04:04 +00:00
aiongg 2a1ac6a6fb Merge PR #6208: Preferences: Save in Background off saves in the foreground (#6207)
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
2026-10-21 05:04:04 +00:00
aiongg 050caf9ecd Merge PR #6681: Content-Aware Move: live preview for large selections (TOOL-213-4, #6680)
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/target-app-parity.md
2026-10-21 05:04:04 +00:00
aiongg 4804148ff9 Merge PR #3759: Pattern Stamp and Background Eraser draw live; Spot Healing shows its heal area (TOOL-213-7, #3754)
# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-21 05:04:04 +00:00
aiongg f163e3ce0b Merge PR #6211: Zoom tool: hold to zoom continuously with Animated Zoom on (#6210)
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-21 05:04:03 +00:00
aiongg 1a5d22ca94 Merge PR #6708: Paths: the path selected in the Paths panel is drawn on the canvas (#6550)
# Conflicts:
#	crates/ui-egui/src/vector_ui.rs
2026-10-21 05:04:03 +00:00
aiongg fc371711e6 Merge PR #5847: Shapes: Density and Feather on shape layers (#5846)
# Conflicts:
#	docs/scorecard.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-21 05:04:03 +00:00
aiongg cd710ca898 Merge PR #5622: Filters: Lens Correction, Adaptive Wide Angle and Camera Raw Filter respect locks on smart objects (#5621)
# Conflicts:
#	crates/engine/src/lens_cmds.rs
2026-10-21 05:04:02 +00:00
aiongg 34c5e7d49e Merge PR #3536: History: set the History Brush source from the panel (history.setSource) (#2966)
# Conflicts:
#	crates/engine/src/commands.rs
#	crates/ops/src/lib.rs
2026-10-21 05:04:02 +00:00
aiongg c23a6c9b90 Merge PR #6206: Performance: a cancelled Gaussian Blur job stops within budget (#6205)
# Conflicts:
#	docs/gaps.md
2026-10-21 05:04:02 +00:00
aiongg 375c5e1517 Merge PR #7085: Actions: an imported Crop step in a unit other than pixels is not applied as pixels (#7084) 2026-10-21 05:04:02 +00:00
aiongg 3d233afd31 Merge PR #7028: Transform Selection: an unknown reference point is an error (#7027) 2026-10-21 05:04:02 +00:00
aiongg 4699d3605c Merge PR #7026: Automation: place and layer/data-set export by index keep the active document (#7025) 2026-10-21 05:04:01 +00:00
aiongg 7db7f847c4 Merge PR #7024: Automation: doc.save rejects a bad quality instead of ignoring or clamping it (#7023) 2026-10-21 05:04:01 +00:00
aiongg 26844944b9 Merge PR #7005: Keyboard Shortcuts: switching sets keeps action function keys (#7004) 2026-10-21 05:04:01 +00:00
aiongg de03dd43f1 Merge PR #6861: CLI batch: skip shell-only Save and view steps inside played action sets (#6860) 2026-10-21 05:04:01 +00:00
aiongg 1637ae25e1 Merge PR #6805: Droplets: carry the actions that Conditional steps call (#6804) 2026-10-21 05:04:01 +00:00
aiongg 9d5bf2a82c Merge PR #6773: Actions: renaming an action updates the Conditional steps that call it (#6772) 2026-10-21 05:04:01 +00:00
aiongg dc8b8834e0 Merge PR #5889: Image: Bitmap dialog with Output resolution and per-method settings (#5559) 2026-10-21 05:04:01 +00:00
aiongg 40895532aa Merge PR #5840: Export As: Resample menu, incl. Nearest Neighbor (#5838)
# Conflicts:
#	docs/scorecard.md
2026-10-21 05:04:01 +00:00
aiongg 5ec2b90f1b Merge PR #7771: Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) 2026-10-21 05:03:49 +00:00
aiongg c633674b6d Integration: one smart-filter count (41 plus Hue/Saturation, Perspective Warp, Filter Gallery and Camera Raw) in the parity doc and FILE-215-2, for #6317 2026-10-21 01:21:19 +00:00
aiongg 47488dff8e Integration: recount KNOWN smart filters (41), for #6317 2026-10-21 01:21:06 +00:00
aiongg bfce71cddd Integration: name #6317's test item pixelate_item (measured_item is taken), for #6317 2026-10-21 01:18:45 +00:00
aiongg af3dff34a6 Merge PR #6317: PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:18:35 +00:00
aiongg 3fee618fd6 Integration: recount KNOWN smart filters (39), for #6305 2026-10-21 01:18:16 +00:00
aiongg bc248fbcec Integration: name #6305's range check long_in_range and its test item measured_named_item (int_in and measured_item are taken), for #6305 2026-10-21 01:18:07 +00:00
aiongg 2c87bd630b Merge PR #6305: PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:17:09 +00:00
aiongg c8979b69e8 Integration: recount KNOWN smart filters (37), for #6315 2026-10-21 01:16:51 +00:00
aiongg 5a8a2be4d5 Integration: Wind, Extrude and Tiles use the shared enum_from, enum_out, int_in and pi_in, for #6315 2026-10-21 01:16:41 +00:00
aiongg 2c0d232083 Merge PR #6315: PSD: Wind, Extrude and Tiles smart filters open and save like Photoshop (#6314)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:16:28 +00:00
aiongg 0dcf8e8e0f Integration: recount KNOWN smart filters (34), for #6303 2026-10-21 01:15:44 +00:00
aiongg 99c94f1435 Integration: drop the duplicate int_in and name the enum reader enum_from (enum_param is #6299's writer), for #6303 2026-10-21 01:12:32 +00:00
aiongg 7c92cb988c Merge PR #6303: PSD: Diffuse, Trace Contour, Crystallize and Pointillize smart filters open and save like Photoshop (#6302)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:11:06 +00:00
aiongg 47b282cf50 Integration: recount KNOWN smart filters (30), for #6301 2026-10-21 01:10:15 +00:00
aiongg 73a52e1aa4 Integration: drop the duplicate int_in and ps_item (kept from #6299), for #6301 2026-10-21 01:10:08 +00:00
aiongg 9fbb648f8a Merge PR #6301: PSD: Smart Sharpen and Dust & Scratches smart filters open and save like Photoshop (#6300)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:09:51 +00:00
aiongg 58dbb8f0af Integration: recount KNOWN smart filters (28), for #6299 2026-10-21 01:09:20 +00:00
aiongg 95256cc86a 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
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:09:08 +00:00
aiongg c7ece37b15 Merge PR #6297: PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
# Conflicts:
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:08:09 +00:00
aiongg 3a3796af05 Merge PR #6397: PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:07:59 +00:00
aiongg 61ef780550 Merge PR #6398: PSD: Hue/Saturation smart filters open and save like Photoshop (#6267) 2026-10-21 01:07:14 +00:00
aiongg d1b602212a Merge PR #7066: PSD: Hue/Saturation export writes each triplet in its own slot (#7065) 2026-10-21 01:07:14 +00:00
aiongg 0028c303e9 Merge PR #6707: Shortcuts: a command shortcut leaves the focused menu bar and runs (#6573) 2026-10-21 01:06:18 +00:00
aiongg bc6a09da25 Merge PR #7037: Match Zoom: matched views stay within each document's zoom range (#7036) 2026-10-21 01:06:18 +00:00
aiongg 831afd2408 Merge PR #7008: Timeline: a longer duration moves a work area that reached the old end (#7006) 2026-10-21 01:06:18 +00:00
aiongg 12c397a4b9 Merge PR #6989: Control: ui.set rejects zoom, rotation, center and dockWidth beyond f32 range (#6988) 2026-10-21 01:06:18 +00:00
aiongg b5f48b3d7b 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 01:06:17 +00:00
aiongg 4f4dcb3a01 Merge PR #7748: Brush smoothing: catch-up compares the pose too, not only the position (#7747) 2026-10-21 01:06:17 +00:00
aiongg 8c33acf7cc Merge PR #7733: Painting symmetry: canvas edits move the axis with the pixels (#7732) 2026-10-21 01:06:17 +00:00
aiongg ac07901f1f Merge PR #6859: Guides: ruler guide snapping rebuilds its targets when snap options change (#6858) 2026-10-21 01:06:17 +00:00
aiongg ce9c669584 Merge PR #6822: Type: paragraph box handles are picked at the unsnapped press (#6819) 2026-10-21 01:06:17 +00:00
aiongg 13a615ca2f Merge PR #6357: Move tool: Ctrl-hover shows the distance to another layer (#4946) 2026-10-21 01:06:17 +00:00
aiongg 6cbfbfeda3 Merge PR #6721: Paths: drag a path row onto the Trash to delete it (#6553) 2026-10-21 01:06:16 +00:00
aiongg 3e6614f92b Merge PR #6719: Paths: Delete Path on a shape layer's path row deletes the layer (#6552) 2026-10-21 01:06:16 +00:00
aiongg ac9b33e8b9 Merge PR #5887: Effects: Gradient Overlay transparency follows Smoothness, midpoints and method (#1954) 2026-10-21 01:06:16 +00:00
aiongg 6c2bcd52e8 Merge PR #7079: Merge to HDR Pro and merged duplicates: the Background layer is a real Background (#7078) 2026-10-21 01:06:16 +00:00
aiongg c15c1e214e Merge PR #7766: Layer Style: a new Stroke is 1 px inside black, as in Photoshop (#7765) 2026-10-21 01:06:16 +00:00
aiongg ac11edc9ec Merge PR #7048: Layer Style: gradient overlay and stroke scale stay within 10-150 % (#7047) 2026-10-21 01:06:16 +00:00
aiongg 69954138f9 Merge PR #7001: Stamp Down keeps the lower layer's clipping, label and link (#6999) 2026-10-21 01:06:16 +00:00
aiongg 9b889e1a58 Merge PR #6640: Layers: toggling a clipped adjustment damages only its base (#6535) 2026-10-21 01:06:16 +00:00
aiongg 6172d967ae Merge PR #5784: Paint: Lock Transparency blends source-atop on semi-transparent pixels (#5783) 2026-10-21 01:06:16 +00:00
aiongg 56102a11fc Merge PR #5753: Fill layers: reject non-finite gradient angles and pattern angle/phase (#5752) 2026-10-21 01:06:16 +00:00
aiongg acdf17f858 Merge PR #5680: Layer masks: From Transparency and Mask All Objects convert the Background and match the document depth (#5679) 2026-10-21 01:06:16 +00:00
aiongg 8c942e481d Merge PR #7224: Image: Trim and merged Duplicate report an error on documents over 2^30 px (#7223) 2026-10-21 01:06:16 +00:00
aiongg 73a6dc7215 Merge PR #7125: Image Size: type keeps its pixel size when the resolution changes (#7124) 2026-10-21 01:06:15 +00:00
aiongg d3a78a374a Merge PR #6980: Image Size: Resolution with Resample on keeps the print size in physical units (#6979) 2026-10-21 01:06:15 +00:00
aiongg dd8770f96d Merge PR #6767: Fit Image: a width or height of 0 leaves that side unconstrained (#6766) 2026-10-21 01:06:15 +00:00
aiongg a91466ae3a Merge PR #7762: Perspective Crop: Color Sampler points move with the image (#7760) 2026-10-21 01:06:15 +00:00
aiongg ef91cec53e Merge PR #7136: Perspective Crop: W and H follow the Point/Pica Size preference (#7135) 2026-10-21 01:06:15 +00:00
aiongg 8b1d4e7eed Merge PR #7131: Perspective Crop: switching documents drops the pending box (#7130) 2026-10-21 01:06:15 +00:00
aiongg 41299afec4 Merge PR #7062: Perspective Crop: a drag without area leaves no crop box (#7061) 2026-10-21 01:06:15 +00:00
aiongg f1cc8ad423 Merge PR #7753: Crop: a typed ratio fits from the frame the edit started with (#7752) 2026-10-21 01:06:15 +00:00
aiongg a0078c3e1f Merge PR #7134: Crop: a refused crop keeps the frame for fixing (#7133) 2026-10-21 01:06:15 +00:00
aiongg a9b71c5e95 Merge PR #7731: Crop: Undo/Redo grey out when the frame has no step left (#7730) 2026-10-21 01:06:15 +00:00
aiongg fb578585fe Merge PR #7089: Crop: in default mode, box Undo/Redo keeps the box in place (#7088) 2026-10-21 01:06:15 +00:00
aiongg 2d14c7ceb7 Merge PR #7128: Crop: Straighten is one box Undo step (#7127) 2026-10-21 01:06:15 +00:00
aiongg 0c26c36e55 Merge PR #7746: Crop: a lost Straighten release no longer hijacks the next drag (#7745) 2026-10-21 01:06:14 +00:00
aiongg 5eafbccc21 Merge PR #7041: Crop: the turn readout never shows -0.0° or -180.0° (#7040) 2026-10-21 01:06:14 +00:00
aiongg 434c276eee Merge PR #7039: Crop: W x H x Resolution follows the Point/Pica Size preference (#7038) 2026-10-21 01:06:14 +00:00
aiongg 41819a01e2 Merge PR #7087: Crop / Canvas Size: extending keeps the Background's hidden pixels (#7086) 2026-10-21 01:06:14 +00:00
aiongg c92fa0a1c7 Merge PR #7723: Crop: a W x H crop on an Indexed Color document resamples with Nearest Neighbor (#7722) 2026-10-21 01:06:14 +00:00
aiongg 3807454782 Merge PR #7013: Crop: Color Samplers scale with a W x H crop (#7012) 2026-10-21 01:06:14 +00:00
aiongg d1bed2f98d Merge PR #6644: Hue/Saturation: the Lightness track runs black, grey, white (#6539) 2026-10-21 01:06:14 +00:00
aiongg ea1c6fce1f Merge PR #6781: Apply Image: write into the targeted layer mask (#6780) 2026-10-21 01:06:14 +00:00
aiongg 68f8be3967 Merge PR #5791: PSD Multichannel: alpha channels are stored as-is, not as ink (#5790) 2026-10-21 01:06:14 +00:00
aiongg 81c030f217 Merge PR #7003: Channels: Multichannel conversion keeps the targeted channel (#7002) 2026-10-21 01:06:14 +00:00
aiongg ab88a0e4bb Merge PR #6777: Auto-Align: warp layer masks as masks, keeping what lies outside revealed (#6776) 2026-10-21 01:06:14 +00:00
aiongg 954a7560ef Merge PR #7030: Lens Correction: smart filters keep the exact focal length (#7029) 2026-10-21 01:06:14 +00:00
aiongg f1784d1497 Merge PR #6722: PSD: open an adjustment layer of an unknown type as an unsupported adjustment (#6575) 2026-10-21 01:06:13 +00:00
aiongg ea5aa58fb6 Merge PR #6775: PSD: read colours stored as HSB (#6774) 2026-10-21 01:06:13 +00:00
aiongg de33a1e376 Merge PR #6803: PSD: layered TIFF byte-order conversion swaps every smart filter section (#6802) 2026-10-21 01:06:13 +00:00
aiongg e5f2107095 Merge PR #6765: PSD: layered TIFF byte-order conversion reads the class of Idnt/indx references (#6764) 2026-10-21 01:06:13 +00:00
aiongg b9404a6eda Merge PR #3534: History: panel menu, Step Forward/Backward and Clear History (#2966) 2026-10-21 01:06:12 +00:00
aiongg d4576a0c5a Merge PR #5110: Performance: brush dab on a CMYK document within budget (#5108) 2026-10-21 01:06:11 +00:00
aiongg 5d1f3ec479 Merge PR #5109: Tests: generated large-file fixtures and a P20 number (#5107)
# Conflicts:
#	docs/scorecard.md
2026-10-21 01:06:10 +00:00
aiongg 5c9ba773b4 Merge PR #3516: scorecard: five rows catch up with work already on main
# Conflicts:
#	docs/scorecard.md
2026-10-21 01:06:08 +00:00
aiongg b097800e78 Merge pull request #8095 from aiongg/integration-7
Integration batch: 9 PRs on main
2026-10-20 20:44:51 -04:00
aiongg 24ffcbc4bc Merge pull request #8094 from aiongg/integration-6
Integration batch: 26 PRs from the known-issues list (#7973) on main
2026-10-20 20:44:09 -04:00
aiongg 9ac1d5adc6 Integration: regenerate docs/scorecard.md for the batch
#7813's performance.gpu_adapter is read from the prefs file before the window opens (gpu_startup::read_gpu_adapter), which the prefs audit's heuristic doesn't see, so it counts as unread.
2026-10-20 22:07:10 +00:00
aiongg f679b4cf1f Integration: rustfmt the merged tree, for #6195 and #5857
cargo fmt --check failed on #6195's module order and canvas.rs guard, and on the IMPORT_ONLY line.
2026-10-20 22:07:10 +00:00
aiongg 1f6c7fff60 Integration: keep the folder pickers for Render Video and Video Frames to Layers, for #3223
view_cmds runs before menus.rs, so wrapping these ids replaced main's folder pickers with a text-field dialog and a file picker; file_dialog's render_video test covers the folder route.
2026-10-20 22:07:10 +00:00
aiongg bc1becfca6 Merge PR #3223: fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback 2026-10-20 22:07:10 +00:00
aiongg 6265d67de9 Integration: naming templates and more read-only formats use same_format, for #5857
With a naming template, Format Same still took the raw extension (IMG_0001_001.dng: unknown file format). HEIC, SVG, Cineon, PCX and XPM open but have no writer, so they save as PSD too; DDS has a writer and keeps its format.
2026-10-20 22:07:10 +00:00
aiongg f5c2f2091e Merge PR #5857: Batch: Format "Same" saves camera raws as PSD instead of failing (#5799) 2026-10-20 22:07:10 +00:00
aiongg b86f525724 Integration: drop the clipboard CI job and the GPU evidence test, for #5587
The ci.yml job is a CI-policy change outside the fix; the ignored evidence test only wrote PR screenshots.
2026-10-20 22:07:10 +00:00
aiongg 92513627da Merge PR #5587: Preserve editable clipboard contents when native reads fail 2026-10-20 22:07:10 +00:00
aiongg 3a8ea8d9ad Integration: barrel check reads only the pen, not the global right button, for #6195
GetAsyncKeyState(VK_RBUTTON) reads the physical button system-wide: with swapped buttons a primary click read as right-down, and a right button held over another window counted too.
2026-10-20 22:07:10 +00:00
aiongg 77cd7118ee Merge PR #6195: fix(tablet): support stylus barrel button and brush resize on Windows (#5905, #5364) 2026-10-20 22:07:10 +00:00
David Mann bc5e4d4c8d Merge PR #7948: PSB save: 8-byte-length global blocks use the 8B64 signature, so Photoshop opens the file (#7937)
Integration: #4816's fix-up made `block_signature(sig, version)` crate-visible for the
layerless Indexed/Bitmap writer (`multichannel_map::layerless_file`); #7948 adds the block
key so PSB long-length blocks get `8B64`. Kept #7948's function as `pub(crate)` and pass
the key from the layerless writer too, so an Indexed or Bitmap PSB gets the same fix.
2026-10-20 23:02:48 +02:00
David Mann d089eaa81b Merge PR #7693: perf(compose): Reuse bounded scratch and tone tables 2026-10-20 22:39:29 +02:00
David Mann 651c32b9eb Merge PR #7566: perf(compose, gpu): many-layer composites, P60/P61 on the GPU 111 -> 28 ms 2026-10-20 22:39:26 +02:00
David Mann 6db0473b43 Merge PR #7180: Brushes: accept .abr files up to 1 GiB 2026-10-20 22:39:24 +02:00
David Mann 34c37aa260 Merge PR #6913: PSD: read split ("quilt") warps of placed layers, so Replace Contents keeps their curvature (#4398) 2026-10-20 22:39:22 +02:00
David Mann f498375d52 Merge PR #6272: PSD: render constant RGB noise gradient fills 2026-10-20 22:39:17 +02:00
David Mann 0cbfb859ed Merge PR #6169: Preserve imported PSD font metadata during text edits 2026-10-20 22:39:15 +02:00
David Mann 2adfe0c4e2 Merge PR #5807: Type: keep superscript and subscript when a text layer is re-rendered (#5645) 2026-10-20 22:39:12 +02:00
David Mann e4692cd71c Merge PR #4816: PSD: save Indexed Color, Bitmap and Duotone documents in their own mode 2026-10-20 22:39:10 +02:00
baa854341b Integration: Indexed PSD keeps transparency, ICC and channels (#4816)
Saving an Indexed Color document in its own PSD mode lost data the RGB
file used to keep (the five open CodeRabbit findings, all reproduced):

- an opaque pixel whose colour matched the transparent entry (opaque
  black next to a black transparent entry, common in PNG-8 files) was
  written as that entry, so it turned transparent; a table whose only
  entry is the transparent one now keeps the RGB file;
- PhotoCraft reopened the file with resource 1047 on the table only, so
  transparent pixels came back opaque (Photoshop stores transparent
  pixels of an Indexed file as the 1047 entry, checked on a GIF it
  opened and saved as PSD);
- the ICC profile was dropped (Photoshop writes 1039 for Indexed); only
  an RGB profile is written, as it describes the table;
- alpha channels and the Quick Mask were dropped without a warning; the
  importer doesn't read extra channels of these modes back, so such
  documents keep the RGB / Grayscale file;
- without a transparent entry, empty areas became the nearest colour to
  transparent black instead of white;
- a PSB-sourced 8B64 global block stayed 8B64 in a PSD (Multichannel
  too), which Photoshop refuses.

Tests: one per point in psd_structure.rs; each fails without this commit.

Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:14:08 +02:00
Akos Arinton 425dd76902 test(gpu): a requested backend fails the parity tests at every setup step
With WGPU_BACKEND set, a device or compositor that could not be created still skipped the
tests (`.ok()?`); like a missing adapter, it now fails them. Without WGPU_BACKEND the tests
skip as before.

Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
2026-10-20 22:23:46 +03:00
aiongg 161d707c04 Integration: run retouch blur rows serially on wasm, for #5499
photocraft-algo declares rayon only for non-wasm targets; an unconditional rayon import broke the wasm build. Same cfg split as blur.rs.
2026-10-20 18:41:57 +00:00
aiongg d94c277566 Merge PR #5499: perf(retouch): row-parallel Blur/Sharpen dabs, 2.6-3.3x faster strokes (#5426) 2026-10-20 18:41:28 +00:00
aiongg db56aeb10a Merge PR #7813: Choose the graphics adapter on multi-GPU Linux machines (#1885)
# Conflicts:
#	apps/photocraft/src/gpu_startup.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
#	crates/ui-egui/src/prefs_ui.rs
2026-10-20 18:41:25 +00:00
aiongg 8d00c31ddf Merge PR #6711: fix(ui): disable Batch without a recorded action
# Conflicts:
#	crates/ui-egui/src/view_cmds.rs
2026-10-20 18:40:47 +00:00
aiongg 1ca8a8aad0 Merge PR #7964: Preserve near-black ICC colors on the GPU canvas
# Conflicts:
#	contributors/people.toml
2026-10-20 18:39:43 +00:00
aiongg 8e5b71e6e3 Merge PR #6909: perf(camera-raw): develop the preview off the UI thread (#5964)
# Conflicts:
#	contributors/people.toml
2026-10-20 18:39:39 +00:00
aiongg ad2d420be2 Merge PR #7683: Web: start when the server already inflated the app's gzip part (Apache mod_mime_magic) (#7639) 2026-10-20 18:39:30 +00:00
aiongg 9ddc2805f0 Merge PR #6878: Windows: maximized window lets an auto-hidden taskbar show (#4845) 2026-10-20 18:39:29 +00:00
aiongg 60764172aa Merge PR #7217: GPU startup: Auto on Windows leaves Vulkan out when an Intel adapter is present (#4) 2026-10-20 18:39:29 +00:00
aiongg 1e2bea1060 Merge PR #6395: Windows: PhotoCraft starts without Windows.Data.Pdf.dll (#5884) 2026-10-20 18:39:29 +00:00
aiongg 54a5146754 Merge PR #7728: Paragraph panel: grey the justify buttons on point text (#7713) 2026-10-20 18:39:29 +00:00
aiongg 44ef277439 Merge PR #6693: macOS: list fonts activated outside the font folders (#6683) 2026-10-20 18:39:29 +00:00
aiongg e2169d0dfd Merge PR #7847: Print: Portrait and Landscape in the Print dialog on Windows 2026-10-20 18:39:29 +00:00
aiongg c247a34bb6 Merge PR #7892: Indexed GIF opens in Indexed Color with its colour table, as in Photoshop (#7886) 2026-10-20 18:39:28 +00:00
aiongg c8cf9eaf0d Merge PR #7180: Brushes: accept .abr files up to 1 GiB 2026-10-20 18:39:28 +00:00
aiongg ea7553f031 Merge PR #5111: Large documents: a gigapixel New Document draws without freezing (#4017) 2026-10-20 18:39:28 +00:00
aiongg b82540845f Merge PR #7773: Give a new type layer a full Txt2 object and its own frame 2026-10-20 18:39:28 +00:00
aiongg 0ae688b59a Merge PR #6939: Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off 2026-10-20 18:39:27 +00:00
aiongg 528ae50c7a Merge PR #6920: Pen: a finished path keeps its anchor squares on screen (#6884) 2026-10-20 18:39:27 +00:00
aiongg 20fb59b3f0 Merge PR #6222: Reveal newly created selections after hiding Extras 2026-10-20 18:39:27 +00:00
aiongg 40fed5e8ef Merge PR #5803: Snap: View › Snap off also disables smart-guide snapping (#5782) 2026-10-20 18:39:27 +00:00
aiongg c369483455 Merge PR #7225: Group all selected layers from the Layers panel footer 2026-10-20 18:39:27 +00:00
NetterSilverandClaude Opus 5.5 e86e6910ae fix(psd): bound the base style run a save copies per run
The base run comes from the file and is cloned once per character run, so
it now has to fit the template node budget, and each font is looked up
once per name. Also names the reopened-layer tuple in psd_kerning for
clippy::type_complexity.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 20:07:39 +02:00
f65cea0ea0 Integration: keep split warps past the quilt limit in place on PSD save (#6913)
The reader takes a `quiltWarp` of at most 64 patches along a side, but the
writer wrote any split warp and framed `Trnf` by its control box. A Warp
Grid of 64 plus one more split (65 patches) was refused on reopen while its
corners still framed the warped box, so the layer came back stretched and
shifted ([2,0,0,2,120,210] -> [2.4,0,0,2.4,100,200]). Such a warp is now
written as no warp, with a message, framed by the source rect, as before
#6913, and a stale imported `quiltWarp` is dropped.

Co-authored-by: Thang Nguyen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:53:57 +02:00
David MannandClaude Opus 5.5 b3dfd1bf6a Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:51:52 +02:00
NetterSilverandClaude Opus 5.5 20c9103148 fix(psd): write complete Txt2 style runs from the layer, number only file indices as kept
Photoshop reads the character style from Txt2 and quit on the bare run
(`/0 0 /11 mode`) that new objects got. New and edited objects now get one
style run per character run, built on one of the object's own runs with
every key kept, and font, size, faux styles, leading, scales, tracking,
fill colour, OpenType keys and auto-kern mode set from the layer. A font
the block's font list lacks is appended to it. Edited objects therefore no
longer keep the old text's features by position.

build_tysh no longer writes TextIndex 0 for a layer without one: the
export took it as a kept number, so a Type tool layer landed on object 0
(a deleted layer's, or the one of a layer above it). set_text_index puts a
missing TextIndex right before EngineData, where Photoshop writes it.

pad_frames pads to at most MAX_TEXT_INDEX + 1 frames.

edited_text_keeps_the_files_txt2_object (#7220) now expects style runs
that follow the layer's runs and size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 19:48:40 +02:00
David MannandClaude Opus 5.5 a9e779fd92 Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:42:32 +02:00
David MannandClaude Opus 5.5 2a4a15f3ab Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:40:18 +02:00
David MannandClaude Opus 5.5 a23d48f130 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:25:37 +02:00
David MannandClaude Opus 5.5 d449d25d62 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:25:10 +02:00
David MannandClaude Opus 5.5 b22b7b4645 Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:24:57 +02:00
David MannandClaude Opus 5.5 8254fbbd3f Merge main into feat/file-215-2-noparam
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:24:32 +02:00
David MannandClaude Opus 5.5 b1b4a54e4a Merge main into fix/headless-save-quality
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:24:12 +02:00
David MannandClaude Opus 5.5 106600284f Merge main into feat/file-215-2-blur
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:52 +02:00
David MannandClaude Opus 5.5 f83bbd0a14 Merge main into feat/file-215-2-sharpen-noise
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:20 +02:00
David MannandClaude Opus 5.5 738541efda Merge main into perf/p25-blur-cancel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:08 +02:00
David MannandClaude Opus 5.5 31dd6e7e41 Merge main into feat/ui-217-4-animated-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:08 +02:00
David MannandClaude Opus 5.5 a939785d63 Merge main into feat/ui-217-1-tool-options-persist
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:07 +02:00
David MannandClaude Opus 5.5 6ff19e55c0 Merge main into feat/file-215-2-stylize-pixelate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:22:51 +02:00
David MannandClaude Opus 5.5 83aec38ba6 Merge main into feat/file-215-2-other
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:22:08 +02:00
David MannandClaude Opus 5.5 7240785492 Merge main into feat/file-215-2-wind-extrude-tiles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:21:36 +02:00
David MannandClaude Opus 5.5 60ba61b1a4 Merge main into feat/file-215-2-mezzotint-halftone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:21:06 +02:00
David MannandClaude Opus 5.5 f81230d86f Merge main into fix/6268-psd-perspective-warp-sf
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:20:37 +02:00
David MannandClaude Opus 5.5 c033313aae Merge main into fix/6267-psd-hue-sat-sf
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:19:06 +02:00
David MannandClaude Opus 5.5 73ba0b7a68 Merge main into feat/tool-213-4-cam-preview-large
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:12:13 +02:00
David Mann 38605ceac5 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-20 19:05:39 +02:00
David Mann c2ce75f9b7 Merge main into feat/ui-217-4-save-in-background
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
2026-10-20 19:05:35 +02:00
Akos Arinton e4c2867494 fix(gpu): batch runs no longer depend on the prefix cache
`batch_runs` cut a run at the pass where the render captured its prefix checkpoint, so a cached
render grouped plain layers differently from an uncached one. On D3D12 with FXC a layer's bits
batched and drawn alone differ by about 1 ULP, so reuse stopped being exact there (#5286):
prefix_follows_an_edit_up_the_stack_and_stays_exact and
chunks_keep_their_own_checkpoint_after_a_partial_refresh failed on an RTX 5070 Ti.

The runs now depend on the plan alone and are computed once per render. `run_starts` marks the
passes a run starts at, and the prefix cache captures and resumes only at checkpoints there, so a
resumed render draws the passes above in the same runs as an uncached one. It may resume up to
three layers lower than before, one more batched pass.

The cache tests give their layers a reveal-all mask so each draws alone and keeps a checkpoint;
prefix_follows also runs on batched layers and checks that a capturing render batches like an
uncached one, which fails on the old executor on any backend.

Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
2026-10-20 20:05:27 +03:00
David Mann 23b94c6f54 Merge main into fix/6573-menubar-shortcut
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/ui-egui/src/menu_keys_tests.rs
2026-10-20 19:05:23 +02:00
Akos Arinton bc1c75d303 test(gpu): parity tests on the backend WGPU_BACKEND asks for
wgpu's Instance::default() ignores WGPU_BACKEND and WGPU_DX12_COMPILER, so the parity tests
always took the default adapter: Vulkan on a Windows machine that has a driver for it, which hid a
D3D12-only difference. They now read both, and a backend asked for that has no adapter or can't
render Rgba32Float fails the tests instead of skipping them.

Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
2026-10-20 20:05:00 +03:00
Guillem Hermida 872748fb5b Codecs: save DDS textures, uncompressed or BC1–BC3, with optional mipmaps (#1327) (#5993)
DDS textures can now be saved, not just opened: uncompressed (BGRA, BGRX, L8, A8L8; lossless) or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, with an optional box-filtered mip chain, in the legacy header. Save As and Save a Copy ask with DDS Options; scripts pass `ddsCompression`/`ddsMipmaps` (file commands, control `app.save`, RPC `doc.save`, MCP `doc_save`/`doc_export`), and the CLI takes `--dds-compression`/`--dds-mipmaps`. Targa and DDS answers travel together as `FormatChoices` through the Save As flow.

Review follow-ups:
- File › Save behind DDS or Targa Options records a plain Save with its answer, and replaying `file.save` applies it (#3531).
- The block encoder finds the principal axis from every channel, so a channel that varies on its own can't hide the others' variation.
- DDS joins the shared round-trip suite; the header is built before the output buffer is allocated.
- Enter on a focused Cancel in DDS, Targa or TIFF Options cancels instead of saving.
2026-10-20 11:02:39 -06:00
David MannandClaude Opus 5.5 ce7e4f3752 Shape Density/Feather test: drag the curved Feather track from main (#6545)
Main made the Properties Feather slider's track cubic (#6727), so a
1% drag no longer reaches 10 px; drag a fifth of the way (8 px) as
main's own mask test does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:44:35 +02:00
Brandon ThomasandClaude Opus 5.5 9b9643764f Skip CodeRabbit reviews on Dependabot PRs (#7991)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:38:22 -04:00
Dylan M. Taylor 8e7841554e Merge pull request #7429 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:30:45 -04:00
David Mann e7f66e46b7 Merge main into feat/file-216-2-large-fixtures 2026-10-20 17:29:22 +02:00
David Mann ca506335e0 Merge main into fix/fill-layer-angle-finite 2026-10-20 17:18:33 +02:00
David Mann 1b0b3e218c Merge main into fix/6267-psd-hue-sat-sf 2026-10-20 17:13:41 +02:00
David Mann 0b39361076 Merge main into feat/tool-214-12-shape-feather 2026-10-20 17:12:29 +02:00
David Mann a3196466a3 Merge main into fix/6268-psd-perspective-warp-sf 2026-10-20 17:09:56 +02:00
David Mann 2cb994459f Merge main into feat/file-215-2-mezzotint-halftone 2026-10-20 17:01:53 +02:00
David Mann f119922f01 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-20 16:58:51 +02:00
David Mann dd9671e8c1 Merge main into feat/file-215-2-wind-extrude-tiles 2026-10-20 16:57:02 +02:00
David Mann 19d69fd2c7 Merge main into feat/file-215-2-other 2026-10-20 16:52:07 +02:00
David Mann f5c78f7419 Merge main into feat/file-215-2-stylize-pixelate 2026-10-20 16:47:59 +02:00
David MannandClaude Opus 5.5 2f96260c99 Crop: the rotated target-size sampler test expects the turned pixel (10, 9) doubled (#7012)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 16:45:42 +02:00
Thang Nguyen f8d5345a30 fix(psd): write split warps back as quiltWarp
Reading `quiltWarp` left the save path behind, so two warps didn't survive
save and reopen:

- A split warp was written as `warpNone` while `Trnf` was the image of its
  control-point box, so the layer came back stretched and shifted.
- A template's `quiltWarp` was kept when `warp` was rewritten, and reading
  prefers it, so a warp changed to Arc, to none or to another mesh came back
  as the imported split.

`SoLd` now writes a split warp as Photoshop does: a `quiltWarp`
(`deformNumCols` x `deformNumRows` control points, the patch boundaries in
`quiltSliceX` / `quiltSliceY`) before a `warpNone` `warp`. An imported
`quiltWarp` stays as stored while the layer's warp is unchanged, keeps its
slices (Photoshop's units and padding) when the mesh is edited on the same
grid, and is rewritten or dropped otherwise. `PlLd`, which has no split
warp, stores none, with its corners framed by the source rect.
2026-10-20 21:43:56 +07:00
David Mann 7d4f756d2c Merge main into feat/file-215-2-sharpen-noise 2026-10-20 16:36:42 +02:00
David Mann 8aaf526108 Merge main into feat/file-215-18-export-resample 2026-10-20 16:36:25 +02:00
David Mann a738fcb9fe Merge remote-tracking branch 'origin/main' into HEAD 2026-10-20 16:34:25 +02:00
NetterSilverandClaude Opus 5.5 760109e23c fix(psd): give a new Txt2 object its own style and a number past the file's objects
A new object kept the template's style runs, and PhotoCraft's reader takes
font features from Txt2, so a new type layer came back with the template
layer's features (salt, ss01). Its style runs are now generated like the
stub's; only the template's structure is copied.

New type layers were numbered past the kept TextIndex values only, so after
deleting the layer with the highest index a new layer landed on its object
and took its style. They are now numbered past the file's Txt2 objects too.

txt2_extras_survive_a_resave_of_unchanged_text now gives its layer the TySh
index a file's layer has; without one the layer is new and no longer lands
on object 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 15:57:10 +02:00
Derek GliwaandClaude Opus 5.5 d53101c9a1 Brushes: keep the 512 MiB .abr cap on 32-bit targets
On wasm32 a near-1 GiB .abr arrives as base64 and is copied several times
before decoding; with a 1 GiB sample budget the import could pass 4 GiB and
abort the tab instead of failing cleanly. Pick the decoder budget by pointer
width (like MAX_DECODED_BYTES) and derive the engine's file cap from it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:53:55 -05:00
Rene Leonhardt a686a932c0 perf(compose): reuse bounded scratch and tone tables
Reuse tile scratch and tone tables across rendering work. Skip fully masked
adjustments and identity float curves. Previous-path tests preserve output
across sample depths, masks and clipping.
2026-10-20 15:52:44 +02:00
Derek Gliwa 4992a4305e Merge remote-tracking branch 'upstream/main' into abr-1gib-limit 2026-10-20 08:35:56 -05:00
David MannandClaude Opus 5.5 11d3026eeb Merge main into feat/file-215-2-blur
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:13:56 +02:00
David MannandClaude Opus 5.5 e05142c450 Merge main into feat/file-215-2-noparam
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:11:38 +02:00
Brandon ThomasandClaude Opus 5.5 46e538b3a2 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:47:46 -04:00
Brandon ThomasandClaude Opus 5.5 8ede07b096 Format: build on FreeBSD (rustix has no xattr calls there)
The 0.11.0 release's FreeBSD job failed: copy_xattrs used rustix's
fsetxattr/flistxattr/fgetxattr, which rustix offers only on Linux,
Android and Apple systems. The real copy is now gated to those, and
other Unix systems get a no-op copy_xattrs, as Windows has. The xattr
tests are gated the same way; the Unix mode test now also checks that
no temporary file is left behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:47:42 -04:00
Brandon ThomasandClaude Opus 5.5 cd79a5a9e8 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:23:58 -04:00
ha1des 472a8d6f29 Preserve near-black ICC colors on the GPU canvas 2026-10-20 14:18:00 +02:00
NetterSilverandClaude Opus 5.5 304f055021 fix(psd): point a new Txt2 object's frame strike at its own frame
new_object copied the template's rendered strikes, whose frame strike
(/99 /F) still named the template's frame in /10. It now names the new
object's frame, like the view's /0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 14:04:33 +02:00
Korg1ch 09749739ad test(io): a PSB's lnk2/pths/cinf are written as 8B64 with an 8-byte length (#7937) 2026-10-20 13:38:41 +03:00
Korg1ch c706938bde fix(io): PSB writes 8-byte-length global blocks with the 8B64 signature (#7937) 2026-10-20 13:38:07 +03:00
Anim86 8fc62f55ab fix(tablet): support stylus barrel button and brush resize on Windows (#5905, #5364)
On Windows with Windows Ink (WM_POINTER), winit drops penFlags (PEN_FLAG_BARREL) and reports stylus contact as WindowEvent::Touch, which egui-winit maps exclusively to PointerButton::Primary. Additionally, Windows Ink does not update GetAsyncKeyState(VK_RBUTTON) when pressing the pen barrel button. Consequently, Alt + Barrel Button drag was recognized as Alt + Left Drag (triggering the eyedropper and painting strokes instead of brush resize), and right-clicks with the stylus were treated as left-clicks.

This commit resolves the issue by:
1. Adding a native Windows message hook (WH_GETMESSAGE) in crates/tablet/src/windows.rs calling GetPointerPenInfo to intercept PEN_FLAG_BARREL (0x00000001) and POINTER_FLAG_SECONDBUTTON.
2. Exposing 
ight_button_down in Services combining the pointer hook and GetAsyncKeyState(VK_RBUTTON).
3. Updating canvas.rs and paint_mouse.rs to prevent strokes_live and alt_eyedropper from hijacking the stylus gesture when the barrel button is held, and correctly routing resize events.
4. Adding tests asserting that Alt + stylus barrel drag resizes diameter horizontally and adjusts hardness vertically without triggering eyedropper or painting.
2026-10-20 11:17:58 +02:00
NetterSilverandClaude Opus 5.5 78cc36f24a fix(psd): keep preserved TextIndex values when a type layer is added below them
Text indices were assigned in one bottom-up pass, so a new layer took the
next free number before the layers above it claimed the numbers their TySh
keeps. Each of those was then renumbered onto its neighbour's Txt2 object
and took its style (70 of 74 layers on the #7698 file after adding a layer
at index 5). Kept numbers are now claimed first and new layers get numbers
past them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 10:51:26 +02:00
fjamespriceandClaude Opus 5.5 2eb68c7872 fix(camera-raw): a later preview frame clears a worker failure from the status bar
A failed proxy develop on the worker set the status error, and nothing took
it down once a later develop succeeded. The dialog now remembers the message
and the preview revision it failed on, and clears the status when a newer
frame is published, unless something else has reported since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 04:39:28 -04:00
fjamespriceandClaude Opus 5.5 c57947d6d8 test(camera-raw): settle() fails when the proxy worker's develop fails
poll_render clears the pending develop before it reports the worker's
error, so previewPending turned false with no new frame and settle()
returned as if one had landed. It now fails with the status message
when the dialog reported an error. With the worker forced to fail, all
21 scope tests passed before this change; now the one that settles
after a worker develop fails with "the Camera Raw preview worker
failed".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:44:33 -04:00
jonathanwxh-cell 8f4db4bc4f fix: integrate latest upstream and preserve editable clipboard copies 2026-10-20 11:50:34 +08:00
Korg1ch e92e5164a8 test(io): an indexed GIF keeps its table and transparent entry; malformed GIFs give no table (#7886) 2026-10-20 06:18:48 +03:00
jonathanwxh-cell 40130ae99e fix: reconcile PR with upstream changes 2026-10-20 11:16:40 +08:00
aiongg 03d0c7e3b3 Merge pull request #7823 from aiongg/integration-5
Integration batch: 8 PRs on main
2026-10-19 23:11:09 -04:00
fjamespriceandClaude Opus 5.5 bdf2e6cb31 test(camera-raw): wait for the proxy worker before recording a revision
The dialog's own controls develop the preview on a worker (#5964), so after
the scope tone drags its last frame could land after the test recorded
previewRevision and bump it during the hover check that must not re-render.
A settle() helper waits until previewPending is false; the fixture and the
drag test call it before taking a baseline.

With a 20 ms delay added to the worker, the old test failed 9 of 10 runs
with the reviewer's assertion; with settle() all 21 tests passed 10 of 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:02:04 -04:00
fjamespriceandClaude Opus 5.5 eafc410ce2 contributors: credit fjamesprice by GitHub profile name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:02:04 -04:00
fjamespriceandClaude Opus 5.5 f708151c91 perf(camera-raw): develop the preview off the UI thread (#5964)
The Camera Raw dialog developed its preview proxy on the UI thread at the
start of every frame after a control changed. With Shadows or Highlights
set that develop takes 190-250 ms on a 24 MP document (858x572 proxy), so
each frame of a slider drag blocked for that long.

The dialog's own controls now hand the develop, histogram and display
conversion to a worker. One runs at a time; settings changed meanwhile are
developed when it finishes, and the preview shows the latest finished
frame. The control channel and the first frame still render on the UI
thread so a reply describes its own settings, and such a render drops any
worker still running with older settings. The wasm build keeps rendering
synchronously. describe() reports previewPending.

Release build, 6000x4000 document, 20 Highlights/Shadows edits:
worst UI frame 237 ms before, 35.5 ms after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:01:57 -04:00
Korg1ch 04a2169b44 fix(io): an indexed GIF opens in Indexed Color with its colour table (#7886) 2026-10-20 05:45:16 +03:00
Michael Higgins b05b641122 Merge branch 'storytold:main' into fix/5799-batch-same-format-raw 2026-10-19 19:34:48 -04:00
jonathnwxh 28f27f5a11 Index imported font metadata fallback by name 2026-10-20 07:14:09 +08:00
Hector VillarrealandClaude Opus 5.5 a2f63703a1 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:29 -06:00
WolfandClaude Opus 5.5 f8117a535c Print: a turn takes settings and paper from one answer (#7847 review)
The Orientation buttons stored the turned settings from file.print.page
and then asked the driver again (driver_page) for the paper. If that
second query failed, the dialog kept the new settings with the old paper.
take_turned_page now takes both, and the orientation, from the one
answer; an answer without both leaves the dialog as it was and shows why.

The no-driver test used "00ff", which fails when the settings are read,
before any printer is asked, so it never tested the failed-driver path.
It now uses valid settings (a minimal DEVMODEW) and asserts the error
comes from the printer, not from reading the settings; with "00ff" it
fails. a_turn_takes_settings_and_paper_together covers the success path
and partial answers.

Reported by CodeRabbit in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:54:46 +02:00
Ernie SmithandClaude Sonnet 5.5 53effe3c83 Review: keep the missing-adapter note, and forward the override in CPU mode
- A requested adapter that couldn't be used, followed by a software window
  adapter, lost its System Info note to the software-graphics message. Append
  instead of replacing.
- PHOTOCRAFT_GPU_ADAPTER only forces its own process when startup acts on it
  (Linux, outside CPU mode); in CPU mode it is ignored, so the launch may
  still go to the running window.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 16:31:57 -04:00
Gert Pellin 3f3d61a7ae Merge branch 'main' into feat/4814-psd-palette-modes 2026-10-19 22:27:21 +02:00
Ernie SmithandClaude Sonnet 5.5 0e97ac4ca8 Make adapter choice Linux-only everywhere, not just the dropdown
The Preferences row was Linux-only, but PHOTOCRAFT_GPU_ADAPTER and
performance.gpuAdapter were honoured on every platform, so Windows and macOS
could pick a non-default adapter without being tested for it. Ignore the
request off Linux (and in CPU mode, as before), and don't make a launch
standalone for a variable that is ignored there.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 16:19:51 -04:00
Ernie SmithandClaude Sonnet 5.5 2644d06081 Review: rustfmt, cap the env adapter name, docs in their own paragraph
- cargo fmt on the adapter selection and its launch code.
- PHOTOCRAFT_GPU_ADAPTER is capped at 256 characters like the saved preference.
- Put the multi-GPU paragraph in docs/development.md on its own instead of
  appending to the --safe-gpu paragraph, so the diff only adds lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 16:07:04 -04:00
WolfandClaude Opus 5.5 2f08860383 Print: Portrait and Landscape in the Print dialog on Windows
On Windows the Print dialog takes the paper and its orientation from the
printer driver, and had no way to change the orientation short of the
driver's own Print Settings… dialog. Photoshop has Layout buttons for it.
A print laid out landscape on portrait driver settings (from a script,
the CLI or another app) stopped with "the driver paper or orientation
differs from the print layout".

- The dialog has an Orientation row (Portrait, Landscape) under Print
  Settings…. It turns the driver settings through file.print.page and
  takes the paper from the driver again, so the paper and preview turn.
- file.print.page takes "orientation": "portrait|landscape" and returns
  the turned settings as printerSettings, with the page they give.
- print_windows turns the settings (the given ones or the printer's
  defaults) to the layout's orientation when the driver has the layout's
  paper the other way round; another paper is still refused.

Turning sets dmOrientation and its dmFields bit in the DEVMODE and keeps
every other byte, the driver's private settings included; the driver
validates the result as it does settings from Print Settings…. No new
UI strings: "Orientation", "Portrait" and "Landscape" are in every
catalog.

Tests: the turned settings (orientation set, all else kept, with and
without private driver bytes), when a paper counts as turned, an unknown
orientation refused before any printer is asked, the dialog's reading of
the driver paper and its error path, and, run by hand on Windows, a
real "Microsoft Print to PDF" driver turning for a landscape layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 21:46:41 +02:00
aiongg 46f7e2422e Integration: drop #7690's benchmark report, as #7690 did before it merged 2026-10-19 18:41:15 +00:00
Michael Higgins 7a8ec58e38 Merge branch 'storytold:main' into fix/6884-pen-tool 2026-10-19 14:32:11 -04:00
Brandon ThomasandClaude Opus 5.5 3a896a5efc docs: regenerate the scorecard (Show File Name In Title adds a preference) (#7829)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:23:47 -04:00
d5c7edacdf Layered TIFF: write PSD-style layer data unless a length overflows (Photoshop opened only the composite) (#5514)
The layered TIFF writer let the PSD exporter pick PSB from its size estimate, which counts every
layer plane uncompressed. A document with many canvas-sized layers passes 2 GB there while its
real layer data is a small fraction of it (a 3000×3441 mockup with 180 layers: estimate ~2.57 GB,
real data ~111 MB), so tag 37724 got the PSB-style header ("Adobe Photoshop Document Data
V0002", 8-byte lengths). Photoshop does not read that in a TIFF: it opens only the composite, as
one locked Background.

PsdExportOptions gains `size_limit_psb` (default true, .psd saves unchanged). The layered TIFF
turns it off and writes PSD-style data ("Document Data Block"), falling back to PSB only past
30000 px, when forced, or when a length really overflows 32 bits.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:21:01 -04:00
Marco Arnulfo 4f15642ee8 Toolbar: Edit Toolbar, Quick Mask and Screen Mode in every theme (part of #4653) (#4680) 2026-10-19 14:20:48 -04:00
David MannandClaude Opus 5.5 e306bcd685 Crop: a turned crop rotates only what the frame keeps (#5006) (#5007)
A turned crop rotated the whole canvas, then cropped to the frame. With
Delete Cropped Pixels on, every layer, mask and channel is now trimmed to
the frame's source (plus the interpolation kernel's reach) before the
turn, so the rotation costs the frame's size: a 512x442 frame turned 3°
on a 3859x2594 two-layer document goes from 541 ms to 42 ms.

Rotate Canvas also fills the Background with shared tiles instead of a
canvas-sized float buffer.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:42 -04:00
MarekSpodBiedry 1a03735cc6 fix(engine): apply document-wide image and colour conversions to stored video frames (#4956) (#5078) 2026-10-19 14:20:35 -04:00
Nicolas FalesyandClaude Opus 5.5 8bed609ef0 Flatpak on Wayland: say why a paste finds nothing, and how to paste anyway (#5031) (#5279)
In a Flatpak on Wayland, arboard can't open at all: the sandbox hides the
data-control protocol from it, and --socket=fallback-x11 gives no X server
while a compositor runs. Paste then reported "the clipboard holds no image"
even when it held one. Now the clipboard service reports that it had no
clipboard to read, and the paste shows a notice that explains it and gives
the XWayland command for this install.

Also adds my contributors/people.toml entry.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:20:29 -04:00
Persi f5af2f3ef5 PSD export: check the Background's opacity from its tiles (#5998) 2026-10-19 14:20:22 -04:00
David MannandClaude Opus 5.5 c136d2dd3d Guides: Ctrl/Cmd drags a guide with every tool that moves guides, and the cursor shows it (#6288) (#6294)
With Ctrl (Cmd on the Mac) held over a guide, Photoshop's tools that turn Ctrl into
the Move tool show the guide cursor and drag the guide. Three gaps remained:

- The Frame and Artboard tools showed the guide cursor under Ctrl but took the
  press themselves (a new frame or board), so the guide never moved. Their pointer
  handlers checked the selected tool instead of the effective one.
- The Gradient tool's live mode took a Ctrl press sent with the event (automation,
  `ui.pointer` with `command: true`) instead of handing it to the Move tool.
- With Window > Modifier Keys' latched Ctrl, the press took the guide but the
  pointer kept the tool's own cursor: `command_guide_at`, the decision the cursor
  and the guide context menu use, knew only the selection tools.

`command_guide_at` now covers every `hold_keys::cmd_moves` tool, and not during a
stroke or type editing. Frame, Artboard and live Gradient step aside for a Ctrl
press when they have no drag of their own under way. Tools that keep Ctrl for
themselves (Pen, Hand, Zoom, Crop, type, shapes, slices) and Lock Guides are
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:16 -04:00
David MannandClaude Opus 5.5 5e1d90d86e Preferences: a wrongly typed value resets only that field (#6611) (#6641)
One value of the wrong type in prefs.json (say "recentFileCount": "ten")
made the whole file fail to deserialize, so every preference fell back
to its default for the session, and since the file was never rewritten
it happened again on every launch.

load_prefs_json now reads leniently when the file doesn't deserialize as
a whole: it starts from the defaults and lays the file's values over
them one at a time, descending into section objects and map entries,
keeping every value that still reads and leaving out only the ones that
don't. Colour settings get the same treatment instead of resetting as a
whole. It returns the left-out values (keys and original value); text
that isn't a JSON object still fails as before.

On launch the app shows a notice naming the reset paths and saves once:
the three-way merge base gets the unreadable values, so the merge
replaces (or removes) exactly those in the stored file and keeps
everything else, including keys this version doesn't know.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:09 -04:00
David MannandClaude Opus 5.5 77b845e098 Actions: loading a set renames each Play target once (#6806) (#6807)
Load Actions applied each rename as its own pass over every action in the set, so chained
renames ("A" -> "A 2", "A 2" -> "A 2 2") retargeted a step twice, and with replace:false it
also rewrote steps of actions already in the set. Now one original->new map is applied once
to the newly loaded actions' Play and Conditional (then/else) steps and to function keys.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:02 -04:00
MarekSpodBiedry 2cdc8cb73d Content-Aware Fill: preview at full resolution so it matches the result (#6530) (#6838) 2026-10-19 14:19:55 -04:00
FinnNkandClaude Opus 5.5 e3224000ca fix(psd): make packbits::decode_into crate-private and overflow-safe (#6950)
decode_into computed out.len() + expected unchecked. With a non-empty
buffer and an expected length near usize::MAX it panicked in debug builds
and, in release, wrapped and returned Ok having decoded nothing. Compute
the target with checked_add and report LimitExceeded, and test each packet
as len > target - out.len() so the bound checks cannot overflow either.

The only callers are decode and decode_rle in this module, and the crate
README documents packbits::{encode, decode} as the public API, so
decode_into becomes pub(crate).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:48 -04:00
David MannandClaude Opus 5.5 bf638a7a22 Crop: Enter during a drag ends the drag (#7055) (#7056)
Pressing Enter while the frame is still being dragged commits the crop; commit_crop now ends the drag, so the release no longer restores a frame in the old document's coordinates as a pending crop.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:42 -04:00
David MannandClaude Opus 5.5 b67502e20f Reveal All: one History step named "Reveal All" (#7145) (#7147)
image.revealAll ran image.crop, whose step is labelled "Crop". It now
runs it inside analysis_cmds::compound with the label "Reveal All".

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:35 -04:00
Michael HigginsandClaude Opus 5.5 73b79953e6 Print: Save as PDF gets a Browse… button (#5328) (#7586)
Save as PDF was only a free text field, so saving a PDF meant typing a
full path by hand. Browse… opens the standard Save dialog (pick_save),
suggesting the document's name as a PDF, and puts the chosen path in the
field. file_ui::body now gets the dialog id so the answer can be written
back into the Print dialog on a later frame.

Refs storytold/photocraft#5328

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:28 -04:00
NetterSilverandClaude Haiku 5.5 5a649f8e4e Apply background jobs to the layers they started on (#7620)
Fixes #7366

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 14:19:22 -04:00
David MannandClaude Opus 5.5 2cc107d05f Crop: a ui.pointer click on the frame leaves it alone (#7740) (#7741)
`to_press` and `control_point` read a point in the press-time view through
`remap`, which rounds it through f32 and the view even when the view hasn't
moved. A `ui.pointer` down + up at the same point then came back slightly
apart: the frame moved by a hair and a box Undo step was pushed that undid
nothing visible. Skip the remap while the view is still the press view.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:15 -04:00
98d3ebc1f5 perf(codecs): Avoid redundant PNG filter passes (#7690)
* perf(codecs): Avoid redundant PNG filter passes

Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression.

* docs(perf): measure PNG filtering on real images

* perf(codecs): stop PNG candidates at the winning cost

* Drop the local benchmark report

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:05 -04:00
209a384ef0 Home screen: fit the card and stack the buttons in a narrow window (#5774)
* Home screen: fit the card and stack the buttons in a narrow window

The Home screen's card was a fixed 460 points wide, centred on the canvas, with two
190-point buttons side by side. A tiling window manager can give PhotoCraft a canvas
narrower than that (Hyprland on Omarchy gave a 728-point window, below the 760-point
minimum), so the New document and Open buttons were clipped at the edges.

The card is now as wide as the canvas when the canvas is narrower than 460 points. The
buttons are sized from their labels (so longer translations still fit) and are stacked
when two of them don't fit side by side; the card grows by one row when they stack.

Adds start_screen_buttons_stay_inside_a_narrow_canvas, which fails before this change
(New document starts at x = -1 in a 390-point canvas).

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

* contributors: add TaylorBurke

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:18:33 -04:00
dfe94d6ab9 Actions: export Box Blur, Surface Blur, Flatten Image and Black & White under ids Photoshop runs (#6330) (#6331)
Save Actions wrote these four steps under the four-char codes BoxB, SrfB,
Fltn and BlnW, which Photoshop does not run: executeAction fails with "the
command is not currently available" (measured in Photoshop on a document
with a filled pixel layer; Fltn resolves to the flatness property, not the
flatten event). The names boxblur, surfaceBlur and blackAndWhite and the
code FltI run.

- Box Blur, Surface Blur and Black & White now export by name
  (boxblur, surfaceBlur, blackAndWhite); Flatten Image exports as FltI.
  The descriptors are unchanged.
- Import accepts the names, FltI and flattenImage, and still accepts the
  old codes (a LEGACY_CODES table consulted only for four-char codes), so
  actions PhotoCraft saved before keep loading.
- Tests: each step exports the new id and imports back to the same command
  and params under the new id, the name and the legacy code; a set mixing
  Photoshop-shaped and legacy steps loads and re-saves with the new ids;
  near-miss ids (case, padding, empty, NULs) stay Photoshop steps; broken
  descriptors don't panic.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:18:18 -04:00
Vikram BhaskaranandBrandon Thomas 7200f64bf1 Explain the Memory Usage budget and GPU caps (#6546) (#6892)
* Explain the per-document memory limit and automatic GPU budget caps.

* Add catalog entries for the Memory Usage explanation (#6546).

The new preference strings need a row in every complete-menu language
for the i18n tests.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:17:49 -04:00
Omar SaidahmedandBrandon Thomas cec0e843e6 refactor(algo): route the splitmix64 copies through one function (#7603)
* route the splitmix64 copies through one function

* document the splitmix64 generators

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:17:32 -04:00
Ernie SmithandClaude Sonnet 5.5 0b6480cdc5 Address review: adapter override process, wasm build, saved-name warning
- PHOTOCRAFT_GPU_ADAPTER now counts as a per-process launch option, so a
  second launch with it set doesn't forward to the running window and drop it.
- Gate RenderState::available_adapters to native targets (it doesn't exist on
  wasm, which broke the web build).
- Share the adapter name matcher (engine prefs) between startup and
  Preferences, so a saved fragment like "nvidia" isn't flagged as missing when
  it matches a listed adapter.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 14:13:55 -04:00
Michael Higgins 5e14702c09 Merge branch 'storytold:main' into fix/6884-pen-tool 2026-10-19 14:08:22 -04:00
aiongg 3f509f06ed Integration: regenerate docs/scorecard.md 2026-10-19 18:01:48 +00:00
2ef8a9d3f5 Reject exporting a partial Play Action call to .atn (#7602)
Fixes #7186

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:45:07 -04:00
Korg1chandBrandon Thomas 990483a95e Actions: Apply Layer Comp is recorded by the comp's name, so it plays in any document (#7181) (#7790)
* fix(engine): record Apply Layer Comp by the comp's name, not its document-local id (#7181)

* test(engine): a recorded Apply Layer Comp applies the comp of that name in another document (#7181)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:44:51 -04:00
Hector VillarrealandClaude Opus 5.5 f11e51b899 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:34:06 -06:00
Khawar Zamman WaniandClaude Opus 5.5 caa1539030 Tools: Sample All Layers is each tool's own, as in Photoshop (#5524) (#5570)
One ToolOptions::sample_all_layers was shared by twelve tools, so ticking
it for the Magic Wand or Paint Bucket also turned it on for Blur, Sharpen
and Smudge. A dab of those on an annotation layer then pasted an opaque
copy of the composite there, which sat above the picture and hid every
later blur of it (the sharp round patch in #5524).

ToolOptions::sample_all is now a SampleAllLayers with one flag per tool,
all off by default: Magic Wand, Quick and Object Selection (and Object
Finder), Paint Bucket, Magic Eraser, Spot Healing, Patch, Content-Aware
Move, Remove, Blur, Sharpen and Smudge. Each options bar and command
reads its own tool's flag, and Reset Tool resets only that tool's. The
new key means older preferences drop the shared flag, so every tool
starts with it off.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:33:14 -04:00
FinnNkandClaude Opus 5.5 8c3c3237f4 refactor(engine): drop the second damage pass after single-edit duplicates and pastes (#5880)
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:33:06 -04:00
Omar Saidahmed 1c74da7baa keep layer-based slices when duplicating a document (#6133) 2026-10-19 13:32:59 -04:00
David MannandClaude Opus 5.5 3cafc63656 Free Transform: W and H take a typed length in percent view (#6284) (#6293)
W and H showing percent rejected a typed length ("40px", "2 in", "5cm"),
since the shared unit parser has no length to convert a percentage
against. The fields now carry the box's original side as the length 100 %
stands for, so a typed length becomes that share of it at the document's
resolution, and "50%" typed into a length view is half the original side.
The field keeps its chosen unit; the aspect link and the scale limits
apply as before. Other percent fields (opacity, fill) still reject lengths.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:32:51 -04:00
Josephand00xJ0 80cd36846e GPU canvas: redraw mip levels of 128 px or less whole; fixes AMD Vulkan corruption after partial updates (#6418) (#6438)
* GPU canvas: redraw mip levels of 128 px or less whole; AMD Vulkan corrupted them after partial updates (#6418)

On an RX 6900 XT with Vulkan (driver 26.8.1), a regenerate_mips pass that drew only the damaged
part of a small mip level corrupted texels outside its scissor, at the level's top-left; DX12 on
the same card was fine. A sweep of 15 canvas sizes x 8-bit/float x 6 dabs found 66 of 180 cases
corrupt on Vulkan, every corrupted level at most 64 px; sampling the levels in a shader shows the
same pixels, so it is the texture contents, not the readback.

mip_damage (regenerate_mips's damage arithmetic) now redraws levels of at most WHOLE_MIP_SIDE
(128 px) whole on every adapter: no new passes or textures, only a larger scissor on the
smallest levels. With it the sweep is clean on both backends.

Tests: small_mip_levels_are_redrawn_whole (the rule, no GPU) and
partial_updates_match_a_full_rebuild_at_every_mip_level (fails on the Radeon over Vulkan without
the change, passes with it, like canvas_mips_preserve_opaque_pixels_and_partial_updates).

* contributors: add 00xJS (GitHub profile name)

* Mip sweep test: hold the GPU lock like the other GPU tests (#6438 review)

---------

Co-authored-by: 00xJ0 <[email protected]>
2026-10-19 13:32:42 -04:00
Marco Arnulfo a012894505 Layer Mask Properties: the Feather slider has fine steps near 0 px, as in Photoshop (#6545) (#6727) 2026-10-19 13:32:35 -04:00
MarekSpodBiedry daa9abfe43 Disable Save for Web while a Free Transform is pending (#6737) (#6832) 2026-10-19 13:32:29 -04:00
Vikram Bhaskaran e1c209eadb Hide the Hyphenate checkbox until layout hyphenates (#6710) (#6869)
* Hide the Hyphenate checkbox until layout actually hyphenates (#6710).

The flag still round-trips to PSD and ⌥⇧⌘H still toggles it, but the
Paragraph and Properties panels no longer advertise a no-op.

* Assert Hyphenate stays hidden until layout hyphenates (#6710).

The Paragraph properties and paragraph style options editor now have
tests that fail if the no-op control is shown again.

* Drop the duplicate #[test] on hyphenate_is_not_shown (#6710).

Clippy -D warnings failed on duplicate-macro-attributes and unused mut
on the harness that only queries the label.
2026-10-19 13:32:21 -04:00
Korg1ch 8c2cfdba6a fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612) (#6941)
* fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612)

* test(linux): cover the XDG state recovery folder (#6612)
2026-10-19 13:32:14 -04:00
David MannandClaude Opus 5.5 4252fb7743 Presets: Migrate Presets reads tool presets like the launch loader (#7375) (#7376)
Migrate Presets deserialized the incoming tool presets all or nothing, so
one damaged entry dropped every good one, and it skipped the checks the
launch loader applies: blank or overlong names were imported and then lost
on restart, and a large file pushed the library past MAX_PRESETS. Read the
list with `presets::tools::load` and stop at MAX_PRESETS.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:32:07 -04:00
zrsix1andClaude Opus 5.5 c78ec47f4b Type: a click inside the paragraph box off the glyphs moves the caret (#7606)
While editing paragraph text, a click inside the box that missed the glyphs
(left of a line's start, or below the last line) committed the edit. Like
Photoshop, it now places the caret at the nearest position; a click outside
the box still commits.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:31:56 -04:00
NetterSilverandClaude Haiku 5.5 fe64e5a2bd Keep arbitrary canvas rotation interpolation in ATN export (#7630)
Fixes #7504

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 13:31:49 -04:00
David MannandClaude Opus 5.5 cbe6c4e131 Guides: colours follow Crop, Canvas Size and rotations (#7724) (#7725)
transform_doc_marks moved guide positions but not their colour entries,
which are keyed by exact position: after a crop the coloured guide fell
back to the preference colour and a guide that landed on its old place
took the colour. Map the entries with the same expressions as the
guides (swap orientation on quarter turns, clear on arbitrary angles).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:31:41 -04:00
Khawar Zamman WaniandClaude Opus 5.5 81d5c514e2 perf(photomerge): build multi-band bands in bounded batches (#7758) (#7763)
* perf(photomerge): build multi-band bands in bounded batches (#7758)

panorama::multiband built every source's Laplacian and weight pyramids
at once and kept them all until accumulation, so Photomerge's peak
memory grew with the image count (~5 GB for four 24 MP sources). Build
the bands in batches under a 2 GiB estimate (at least one image) and
add each batch before building the next. Each pixel still adds the
bands in image order, so the output is bit-identical.

Planes now move into the pyramids instead of being cloned, and the
coverage and push-pull masks are freed once used: with one image built
at a time those clones were serial passes. seam_blend shifts its ROIs
in place instead of cloning every source for the blend.

Peak during the blend: 4x24 MP 5.04 -> 2.81 GB, 8x12 MP 5.50 -> 2.61 GB,
at +5% time (2x24 MP unchanged). New example bench_multiband measures it.

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

* perf(photomerge): let up to four 24 MP sources build their bands together

A 2 GiB band budget built every 24 MP source alone, which made the usual
2-4 image Photomerge 12-36% slower on a 32-thread desktop. The native
budget is now 5 GiB, so those still build side by side and only larger
stacks are batched. wasm32 has no threads, so it adds each band as soon
as it is built.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:31:33 -04:00
5b1f937287 Actions: a function key can take Shift and Command (#7646) (#7674)
Photoshop's Action Options pair the function key with Shift and Command
checkboxes. The Actions row menu offered plain F-keys only, and .atn import
dropped any key with a modifier. The row menu gets Shift and Command (Ctrl off
the Mac) toggles, and .atn load and save keep the modifiers.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:31:26 -04:00
acb3a66fd5 Crop: a preset picked in Tool Presets fits the frame to its ratio (#7143) (#7144)
select_tool_preset only merged the preset's tool options, so a crop
preset's ratio was set without refitting the frame; the options bar's
dropdown already refits. It now compares the ratio before and after and
calls crop_ui::apply_ratio when it changed, as the dropdown does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:31:10 -04:00
7b99c32923 Color Lookup: warn when it opens without its usable table (part of #1763) (#6322)
* fix(io): warn when a Color Lookup opens without its usable table

A Color Lookup whose table is shorter than its size-cubed grid (or
missing altogether) silently rendered as the identity. Native .pcraft
loads and native-path automation opens carried no import warnings, so
the loss was silent. The new io::open_warnings reports it naming the
layer, and both load seams surface it.

Also add a Photoshop-layout clrL regression test: a version-1 block
carrying a CUBE descriptor round-trips through PSB and reopens as a
working Color Lookup that recolors a red backdrop (#1763).

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:30:56 -04:00
aiongg acc853b517 Merge upstream main into integration-5
docs/file-format-parity.md: keeps both 2026-10-15 rows, main's #6063 PNG sTER row first, then #5976's PSD guides row
2026-10-19 17:20:29 +00:00
aiongg b8e856dffe Integration: #7022 write_region stays serial; the text rasterizer uses the parallel variant 2026-10-19 17:18:46 +00:00
Ernie SmithandClaude Sonnet 5.5 b892c0412e Choose the graphics adapter on multi-GPU Linux machines (#1885)
With the backend on auto, Linux installed no adapter selector, so wgpu took
the first discrete GPU the driver listed. On a machine with an eGPU, a
laptop dGPU and an iGPU that was not the one the user wanted.

- performance.gpuAdapter: a case-insensitive piece of the adapter's name
  (several words must all appear); empty is automatic.
- PHOTOCRAFT_GPU_ADAPTER overrides it for one launch.
- Preferences > Performance > Advanced > GPU adapter lists the adapters the
  instance found; a saved adapter that is absent stays listed, marked.
- A missing adapter, or one that can't present to the window, falls back to
  the automatic choice with a note in System Info. CPU mode ignores it.
- Shown on Linux only; tested on Linux only. Windows and macOS need their own
  work (the startup code honours the setting there, but it is untested).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 13:16:53 -04:00
125d15961e Actions: Smart Blur quality uses Photoshop's SBQL/SBQM/SBQH codes (#6312) (#6313)
A Smart Blur step saved to .atn wrote its quality as `Hgh `/`Mdm `/`Low `,
codes Photoshop ignores (it plays them back with its fallback, low
quality), and Photoshop's own `SmBQ` values did not import as a quality.

The quality table now writes `SBQL`/`SBQM`/`SBQH` (enum type `SmBQ`) and
reads both those and the old `Hgh `/`Mdm `/`Low `, so actions PhotoCraft
wrote before still load. Unknown codes stay kept-verbatim Photoshop steps.

Measured in Photoshop by rendering: Smart Blur applied through Action
Manager with each code; SBQH and SBQM each give a distinct image, while
`Hgh `, `Mdm `, `Low `, a nonsense code and SBQL give the identical
(fallback) image. The smart filter Photoshop saves uses the same SmBQ
values (applied through Action Manager to a smart object, saved, filterFX
read back).

Tests: every quality round-trips through .atn as SBQ*; Photoshop-shaped
SmrB steps and the legacy codes import as the right quality; unknown,
wrong-type, NaN and missing values never panic; Smart Blur joins the
export/import round-trip table.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:08:50 -04:00
Hector VillarrealandClaude Opus 5.5 f4a81d3c16 Add Liquify's Show Backdrop Use and Mode (#7609) (#7644)
* Add Liquify's Show Backdrop Use and Mode (#7609)

Show Backdrop only had an Opacity: it always drew the other layers'
composite behind the image, faded. Photoshop's Liquify also has Use (All
Layers, or one layer) and Mode (In Front, Behind, Blend). Use and Mode
are now in the View Options and the control channel (backdropUse,
backdropMode), and each mode layers the backdrop and the image as
Photoshop's does: In Front lays the backdrop over the image at the
Opacity, Behind the image over the backdrop, Blend fades the image to
100 - Opacity under the backdrop. All Layers is the document as it is,
as in Photoshop.

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

* Liquify backdrop: show a hidden Use layer, forget the layer between documents

A layer picked in Use, or one in a hidden group, now shows: the
one-layer backdrop makes it and its groups visible, as Photoshop does.
The remembered settings keep the backdrop Mode and Opacity but no longer
a layer id, which belongs to one document: Liquify opens on All Layers,
as Photoshop's does.

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

* Liquify backdrop: a control-channel Use naming no layer is All Layers

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

* Liquify backdrop: normalize an unknown Use even when another setting fails

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:04:59 -04:00
David MannandClaude Opus 5.5 5c1b9425d1 Gradient Overlay: repaint the whole layer when a stroke moves its frame (#4053) (#5340)
A Gradient Overlay (and gradient strokes, linked patterns, linked bevel
textures) is laid out over the layer's bounds. Painting grows those
bounds, but the stroke commands reported only the stroke as damage, and
the canvas's committed-stroke path repainted only what the live stroke
had not shown. The canvas therefore kept the old gradient around earlier
strokes: one gradient per stroke instead of one over the whole layer.

Session dispatch now grows a command's reported damage by
photocraft_compose::relayout_damage(before, after): the whole composite
of any visible layer whose effects span its frame, when that frame moved.
The canvas adds the same area to a committed live stroke. Strokes inside
the existing bounds and frame-independent effects keep tight damage.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:04:52 -04:00
David MannandClaude Opus 5.5 bc4f2a783d Scripts: Flatten All Layer Effects bakes with the document's Global Light and patterns (#5719) (#5720)
`bake` rendered each layer through `render_layer`, which has no document context (default Global
Light, no patterns, the render rect as canvas). It now uses the new doc-aware `render_layer_in`.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:04:46 -04:00
Michael HigginsandClaude Opus 5.5 ac78e0bbca Color Settings: the working-space menus list the installed profiles of their space (#6323) (#6359)
* Color Settings: the working-space menus list the installed profiles of their space (#6323)

Like Convert to Profile and Proof Setup, Edit › Color Settings' RGB, CMYK and Gray working-space
menus now list the profiles installed in the system after the built-ins, by description, so a
press condition (GRACoL, SWOP, FOGRA39) can be the CMYK working space as in Photoshop. Each menu
lists only profiles of its colour space; RGB lists display and colour-space profiles, not
scanner or printer ones. The engine already took .icc paths for these settings.

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

* rustfmt: format after merging main (CI Format step)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:04:38 -04:00
Perchenko Anton bf321f0f5e Affinity: bound compound geometry expansion and reject cycles (#6486)
* fix(affinity): bound compound geometry expansion and reject cycles

* fix(affinity): charge every compound operand slot before filtering
2026-10-19 13:04:32 -04:00
David MannandClaude Opus 5.5 566dbb3f83 Histogram: count the document's pixels, not a blurred thumbnail (#6538) (#6724)
The Histogram panel binned a 384 px area-averaged thumbnail, so the cell
holding a black/white edge came out mid-grey: a spike near 128 and
slightly wrong Mean/Std Dev. Documents up to PROXY_THUMBNAIL_PIXELS are
now composited in bands and every pixel is counted (Cache Level 1, as
Photoshop's uncached histogram); larger ones count a nearest-neighbour
proxy composite, which only holds values the layers have, and report its
cache level. The Pixels row shows the pixels actually counted.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:04:26 -04:00
codexistence 7efaa50455 fix: match Photoshop Gradient Fill scale limits (#6797) 2026-10-19 13:04:20 -04:00
Vikram Bhaskaran 4bf0625b04 Export and import saved tool presets (#6560) (#6889)
* Include tool presets in export and import so crop presets are portable.

* Pass presets_io_body to the harness without a redundant closure (#6560).

CI clippy -D warnings failed on clippy::redundant_closure in the
presets export/import regression.
2026-10-19 13:04:14 -04:00
David MannandClaude Opus 5.5 aa8621788e Minimum / Maximum: CMYK spreads paper white with Maximum, ink with Minimum (#7074) (#7075)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:04:02 -04:00
UnemarsandClaude Fable 5.1 5071472e4d Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop (#7165)
* Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop

⌘-clicking a layer thumbnail loads its transparency as a selection (⇧ adds,
⌥ subtracts, ⇧⌥ intersects) without changing the selected layer. Measured on
Photoshop 25.4: the Background, which has no transparency, is the exception —
the click loads nothing and selects the row like a plain click, where
PhotoCraft selected the whole canvas. A hidden layer still loads.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Layers: the Background thumbnail's ⌘-click selects the Background alone

Review: the fallback to the row handler kept ⌘ set, so `select_mode` toggled
the Background into (or out of) the selected set instead of the plain click's
replace. The Background thumbnail's ⌘-click now selects it alone; the test
asserts the selected set, including when the Background was already part of a
multi-selection.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-19 13:03:56 -04:00
Omar Saidahmed 6385fced3d refactor: use photocraft-color's srgb transfer functions in algo and proof simulation (#7604)
* share the srgb transfer functions from photocraft-color

* document the touched srgb helpers
2026-10-19 13:03:48 -04:00
KnottyDyes c9d7f1d984 Docs: install the CLI and register it with MCP clients (#7663) 2026-10-19 13:03:40 -04:00
David MannandClaude Opus 5.5 baa005d476 Type: formatting commands respect Lock All (#7718) (#7720)
Character (type.setStyle), Warp Text, Anti-Alias, Orientation, OpenType
and Load Default Type Styles all run through with_text_targets, which
never checked Lock All, so they changed a locked type layer and added a
history step. Photoshop refuses all of them ("the layer is locked").
Check every target with check_text_unlocked before the edit, as
type.edit already does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:03:32 -04:00
David MannandClaude Opus 5.5 94a135bc6b Free Transform: show a flip as -100 % on the flipped axis (#7749) (#7750)
The options bar read the box's X scale as never negative and dropped
the sign of the Y scale, so Flip Horizontal read W 100 %, H 100 %, 180°
(typing 0° then flipped the layer vertically) and Flip Vertical read
the same as an untouched box. A mirrored box now reads as a negative W
or H, whichever keeps the angle nearer 0, as in Photoshop (Flip
Horizontal: W -100 % at 0°, Flip Vertical: H -100 % at 0°). Typed W/H
divide by a sign-preserving guard instead of max(1e-9).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:03:26 -04:00
adbd40ef55 Actions: Photoshop Shadows/Highlights steps play (#5830) (#5854)
Imported actions stopped at Shadows/Highlights ("adaptCorrect has no
PhotoCraft equivalent yet"), although Image > Adjustments > Shadows/Highlights
exists. The step now maps to it both ways: shadowMode/highlightMode (amount,
width, radius), black/white clip, midtone contrast and colour correction,
each read as its char ID or string ID.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:03:20 -04:00
96c151525a feat(prefs): Show File Name In Title keeps the title bar to the app name (#5037) (#5129)
Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:02:53 -04:00
Tarek MohamedandBrandon Thomas 5c513b8c88 fix(actions): flush pending recorded steps on save (#7193) (#7244)
When actions.save was called while recording was active, it exported only
already-committed action steps, silently omitting any pending steps shown
in the Actions panel.

Call flush_recording at the beginning of actions.save to commit pending
journal entries to the action and advance the recording pointer, ensuring
all displayed recorded steps are saved while allowing ongoing recording to
continue uninterrupted.

Fixes #7193

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:02:37 -04:00
640873de7b PNG: keep a stereo file's sTER pairing on save (#6063) (#6285)
* PNG: keep a stereo file's sTER pairing on save (#6063)

Read the sTER chunk (PNG Extensions 1.4.0 §3.6) when a PNG opens, store it
on the document's FileEncoding, and write it back when saving, so a stereo
PNG still says it is a stereo pair. Covers the ordinary and the palette
PNG encoders, including interlaced output.

* io tests: pass the new sTER argument to encode_png_indexed

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

---------

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:02:18 -04:00
aiongg 3544721eb0 Merge PR #7692: perf(raster): Avoid redundant default pixel work 2026-10-19 16:40:42 +00:00
Elvis33LE 716d1e24c9 fix(io): keep variable noise GdFl fills as fill layers
constant_rgb_noise_color's None propagated out of fill_from_desc for every
noise gradient that is not provably constant - Photoshop's default Noise
gradient included - so the layer imported as pixels and the export dropped
its GdFl (RASTER_STALE_KEYS): an open-and-save data loss. The exact colour
now applies only when provable; everything else keeps the placeholder ramp,
the fill_cache pixels and the raw GdFl block, which the re-save writes back
byte for byte. Round-trip test included (fails on the old head: the layer
came back as pixels without a GdFl).
2026-10-19 17:24:19 +02:00
Rene Leonhardt 83aff80f35 fix(raster): support sample counts beyond stack capacity 2026-10-19 17:19:18 +02:00
Akos Arinton dfeba9e974 test(compose): compare NaN as NaN in the vector span test, not by its bits 2026-10-19 18:11:13 +03:00
David Mann c30f9180e8 Merge PR #7692: perf(raster): Avoid redundant default pixel work 2026-10-19 16:51:48 +02:00
David Mann c0072ca2da Merge PR #7691: perf(io): Interleave PSD pixels in the final buffer 2026-10-19 16:51:45 +02:00
David Mann b2269e3bdb Merge PR #7690: perf(codecs): Avoid redundant PNG filter passes 2026-10-19 16:51:43 +02:00
David Mann 8e54897d43 Merge PR #7022: perf(text): rasterize a type layer's pixels on all cores, bit for bit the same 2026-10-19 16:51:41 +02:00
David Mann ca915bc096 Merge PR #6950: fix(psd): make packbits::decode_into crate-private and overflow-safe 2026-10-19 16:51:39 +02:00
David Mann cfece486fc Merge PR #6797: Fix live gradient fill scale limits to match OG program 2026-10-19 16:51:36 +02:00
David Mann 10d012b843 Merge PR #6193: fix(perf): validate regression minimum threshold 2026-10-19 16:51:34 +02:00
David Mann 3c9f825269 Merge PR #5998: PSD save: check the Background's opacity without copying the canvas 2026-10-19 16:51:32 +02:00
David Mann b107b172e3 Merge PR #5984: Brushes: importing an .abr file numbers repeated brush names in linear time 2026-10-19 16:51:29 +02:00
David Mann 284ee60705 Merge PR #5976: PSD: keep guide resources 1092/1097 only while they match the guides (#5966) 2026-10-19 16:51:27 +02:00
David Mann 06445a21b8 Merge PR #5880: refactor(engine): drop the second damage pass after single-edit duplicates and pastes 2026-10-19 16:51:25 +02:00
David Mann c2f3e23a40 Merge PR #5477: perf: chisel bevel size changes, CPU passes on threads and GPU fields share their sharp edges (results unchanged) 2026-10-19 16:51:22 +02:00
David Mann 676761c435 Merge PR #5413: Limit blend-mode hover refreshes to the visible canvas region 2026-10-19 16:51:20 +02:00
Gert Pellin bfffc365c6 Merge branch 'main' into feat/4814-psd-palette-modes 2026-10-19 16:33:16 +02:00
David MannandClaude Opus 5.5 ebfe3d98da Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770)
`slices::resolve()` only clipped each stored slice to the canvas, so a slice under a later
one was numbered and exported in full and Save for Web wrote the overlap twice (the HTML
path cut pieces itself, named `<name>_01`, `_02`). As Photoshop does, resolve() now cuts a
covered slice into its visible pieces: the first piece in reading order keeps the name,
every further piece is `<name>-NN` with its own slice number, and auto slices count the
pieces in their numbering (measured in Photoshop: sliceA, <doc>_02, sliceA-03, sliceB,
<doc>_05).

- Save for Web exports resolve()'s pieces as they are, with or without HTML; a no-image
  cell's text goes into its first piece only.
- Slice Options and slice.list show the slice's own name for every piece; slice.set accepts
  a slice that ends up wholly under later ones (number null) instead of calling it off the
  canvas.
- Slice Select drags the whole slice, not the piece that was hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:02:32 +02:00
NetterSilverandClaude Opus 5.5 604007490b fix(psd): replace an earlier save's Txt2 stub also when its text is edited
The stub filter applied only to the kept object, so an edited layer whose
slot held a stub from an earlier save went through edited_object and kept
the bare stub. The slot is now filtered before both lookups.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 15:33:05 +02:00
David MannandClaude Opus 5.5 41fb9f986c Layer Style: a new Stroke is 1 px inside black, as in Photoshop (#7765)
`layer.layerStyle.stroke {}` built a red 3 px outside stroke, while the
dialog's factory default (defaultFor / Reset to Default) was black 3 px
outside. Photoshop's Reset to Default on the Stroke page is 1 px,
Inside, Normal, 100 %, black; both tables now give that, and a test
checks that every effect kind's bare build equals its dialog default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:14:51 +02:00
NetterSilverandClaude Opus 5.5 198fa59c7d fix(psd): a new type layer gets a full Txt2 object and its own frame (#7698)
A type layer the file's Txt2 has no object for (one added in PhotoCraft)
was written as a bare stub with no view, no paragraph runs and no frame
in /0 /8 /0. Photopea and Photoshop refused the file once the objects
outnumbered the frames.

build_txt2 now gives such a layer an object shaped like one of the
file's own (refitted with edited_object from #7220), with its view
naming its own frame, and pads the frame list to one per object. A stub
written by an earlier save is replaced the same way, so re-saving an
affected file repairs it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:56:15 +02:00
David MannandClaude Opus 5.5 745f535fda Perspective Crop: Color Sampler points move with the image (#7760)
Perspective Crop set no sampler map, so the edit only dropped the
points now off the smaller canvas and left the rest at their old
coordinates, reading unrelated pixels. As in Photoshop, each point now
follows its pixel through the crop's homography (fractional positions
kept), and the points outside the quad are deleted and counted for the
status message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:51:25 +02:00
David MannandClaude Opus 5.5 e8a9f579f7 Crop: a typed ratio fits from the frame the edit started with (#7752)
The options-bar ratio fields apply every keystroke, and each ratio was
fitted inside the frame the previous keystroke left: typing 16 over 4 in
a 400 x 300 frame went 1:3 (100 x 300), then 16:3 inside that
(100 x 18.75) instead of 400 x 75. The bar now keeps the frame from when
the W/H edit (typing, arrows or scrubbing) started and fits each new
ratio from it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:44:02 +02:00
David MannandClaude Opus 5.5 de2cb1f0c0 Compose: a clipped adjustment inside a pass-through clip base damages the whole group (#6535)
A pass-through group that is the base of a clipping group renders isolated while it holds a
visible adjustment (`clip_base_adjusts`), so showing, hiding, adding or removing a clipped
adjustment inside it can switch the group's other children (a Multiply layer beside the base)
between isolated and pass-through rendering. `change_bounds_in` now takes the list holding the
parent group and reports that group's change bounds in this case; `layers_damage`, `step_damage`
and `one_sided_bounds` pass it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:14:06 +02:00
David MannandClaude Opus 5.5 d67a949a54 Crop: a lost Straighten release no longer hijacks the next drag (#7745)
A Straighten line whose release went to Space's temporary Hand stayed
set, so the next plain drag fed its moves and release to the old line:
the frame turned instead of moving and the crop gesture stayed stuck.
A new press now drops any leftover line first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:10:07 +02:00
David MannandClaude Opus 5.5 c4e8eb2b86 Brush smoothing: catch-up compares the pose too, not only the position (#7747)
Since #5726 the smoothed brush pulls its pressure, tilt, rotation and wheel
towards the pointer like its position, but Stroke Catch-Up (`lagging`) and
Catch-Up On Stroke End (`finish`) still compared x/y only. A pen that changed
only its pressure in place got no catch-up time and no end point, so the
stroke ended at an in-between pressure (1.0 -> 0.0 ended at 0.64).

Both checks now also compare the pose (rotation on the circle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:07:15 +02:00
Rene Leonhardt 4d50e31efd perf(codecs): stop PNG candidates at the winning cost 2026-10-19 13:43:19 +02:00
David MannandClaude Opus 5.5 b61a6124d9 Crop: Undo/Redo grey out when the frame has no step left (#7730)
While either crop-box stack has a step, intercept takes both Undo and
Redo, but is_enabled reported only the stack it would pop and otherwise
fell back to the document's can-undo: Undo looked enabled, popped
nothing and left the document's history unreachable. Grey the empty
direction instead, as the Free Transform box does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:40:23 +02:00
David MannandClaude Opus 5.5 c7c2c31416 Painting symmetry: canvas edits move the axis with the pixels (#7732)
Crop, Trim, Canvas Size, Image Size, Flip and Rotate Canvas mapped only the
Color Sampler points; the painting symmetry kept its old document
coordinates, so strokes mirrored about a different line (and a path axis
left its own moved path). Session::edit now maps the symmetry through the
same edit's geometry map: preset centre and angle (a flip mirrors it), or
the sampled path segments. Flips and rotations pass their edge-coordinate
map separately from the samplers' pixel-centre map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:40:05 +02:00
Hector VillarrealandClaude Opus 5.5 b862323a8e Justify buttons: grey when any formatted type layer is point text
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:39:46 -06:00
Rene Leonhardt dd552f76b1 docs(raster): document touched read helpers 2026-10-19 13:33:01 +02:00
David MannandClaude Opus 5.5 d20a0782da Crop: a W x H crop on an Indexed Color document resamples with Nearest Neighbor (#7722)
The Crop tool's W x H x Resolution mode resampled with the chosen filter
(Bicubic by default) even on an Indexed Color document, so the result held
blends outside the colour table. Like Image Size (#6019), it now falls back
to Nearest Neighbor there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:28:32 +02:00
Hector VillarrealandClaude Opus 5.5 c8907e2cd2 Paragraph panel: grey the justify buttons on point text (#7713)
Justification needs a paragraph box: Photoshop greys the four justify buttons
for point text, which lays out left whatever its alignment. PhotoCraft offered
them, and choosing one did nothing visible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:23:03 -06:00
Rene Leonhardt b5b5776a30 perf(io): remove the PSD export inlining hint 2026-10-19 13:15:06 +02:00
Rene Leonhardt 78a9982eec perf(raster): share contiguous region reads 2026-10-19 13:15:06 +02:00
Rene Leonhardt 6b1aa81561 docs(perf): measure PNG filtering on real images 2026-10-19 13:15:05 +02:00
Akos Arinton 80f896210a perf(compose): write band tiles as they finish, not after the band
Parallel bands each collected their finished tiles until the band's last
one was done, so with work stealing many bands could hold most of their
tiles at once, close to a second copy of the output. Each tile is now put
into its band behind a lock as soon as it is rendered; only the tiles in
flight are held, as before the bands ran in parallel.
2026-10-19 13:34:32 +03:00
Korg1ch af0857372b test(web): the boot module tells gzip from wasm the browser already inflated (#7639) 2026-10-19 12:37:55 +03:00
Korg1ch cb1b479452 fix(web): start the app when the server already inflated its gzip part (#7639) 2026-10-19 12:37:54 +03:00
Rene Leonhardt 76a6405f9b perf(io): Interleave PSD pixels in the final buffer
Remove intermediate band buffers while retaining byte-for-byte equivalence with the previous path at every sample depth.
2026-10-19 11:31:59 +02:00
Rene Leonhardt f6e7fc3c2a perf(raster): Avoid redundant default pixel work
Preserve default sample bits while skipping absent zero tiles and redundant default-pixel copies. Add the previous read path as the oracle.
2026-10-19 11:31:59 +02:00
Rene Leonhardt 850bf9fdaa perf(codecs): Avoid redundant PNG filter passes
Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression.
2026-10-19 11:31:59 +02:00
jonathnwxh 49d48fc43c Fix blend preview coverage for periodic tiles and multiple views 2026-10-19 16:11:51 +08:00
David MannandClaude Opus 5.5 8534f0c935 Compose: a clipped adjustment with no base inside a pass-through group reaches everything (#6535)
The compositor draws a clipped adjustment that has no unclipped layer
beneath it as an ordinary adjustment, but change_bounds counted it as
contained in a pass-through group. Hiding the layer clipped to such a
group (which ends its isolation) or the group itself then recomposited
only the group's pixels and left the rest of the canvas stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:03:54 +02:00
David Mann 2aea78b657 Merge remote-tracking branch 'origin/main' into fix/6535-clipped-adjust-damage 2026-10-19 09:57:13 +02:00
David MannandClaude Opus 5.5 0604f39d97 Performance: hand single-part tiles to put without a copy (#5108)
Only CMYK and Lab tiles are split into bands; every other format's one part
went through concat, an extra allocation and copy per tile on the UI thread
(8-bit RGBA, 64 tiles: 2.7 -> 3.3 ms). Now it passes straight through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:43:36 +02:00
aiongg d95fdf3181 Merge pull request #7569 from aiongg/integration-4
Integration batch: 12 PRs on main
2026-10-18 23:29:05 -04:00
Brian TorresandClaude Sonnet 5.5 ea0eec98a3 macOS: list fonts activated outside the font folders (#6683)
The installed-font scan read only /System/Library/Fonts, /Library/Fonts and
~/Library/Fonts (plus PingFang). Fonts macOS offers from anywhere else, such
as those a font manager, Adobe Fonts or Office activated, never reached the
font menus although other apps showed them.

load_system_fonts now also registers every file Core Text offers. The list
comes from fontique's Core Text backend, enabled for macOS only, used as a
scratch collection whose families are discarded: only its file paths are read
and registered in our own collection, so generic-family defaults and the merge
of installed faces into the bundled families are unchanged. The reserved UI
fonts under /System/Library/PrivateFrameworks are skipped (fontique cannot
draw their outlines).

On a stock macOS install the folder scan already listed all 183 Core Text
families; the gap is fonts Core Text knows from elsewhere. With Hack activated
from a scratch folder via CTFontManagerRegisterFontsForURL, the list had 313
families before and 314 after (Hack added, none removed), type.fonts lists it
and a type layer renders in it. The scan stays 37-57 ms.

Tests: font_files_of_lists_each_font_file_once (all platforms);
coretext_lists_the_system_font_files and every_family_coretext_offers_is_listed
(macOS). The last fails without the call when a font is activated elsewhere
("lacks: Hack") and passes with it.

Cargo.lock gains fontique's optional per-platform dependencies for the
`system` feature; `cargo tree` shows the feature only for aarch64-apple-darwin.

Still open: Windows fonts outside the two folders (#3336) and the web build.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 16:36:36 -07:00
Elvis33LE e2adb18165 fix(psd): render constant noise gradient fills 2026-10-19 01:05:13 +02:00
Higgs32584andClaude Opus 5.5 732070b22d Print: let the Windows PDF test pass where the system DLL cannot load (#5884)
A stripped Windows image can have Windows.Data.Pdf.dll present but not
loadable; that is the case `require_windows_pdf` reports, so the test now
checks that the result follows the loader instead of assuming the DLL loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:51:08 -04:00
skylar taylor-barrick 4d4de45305 Merge pull request #7206 from skylartaylor/save-replace-metadata
Save: keep the replaced file's metadata
2026-10-18 16:48:40 -06:00
Higgs32584andClaude Opus 5.5 eeeeb13f0e Print: restore the winsafe dependency, the DLL check and its tests lost in merges (#5884)
The merges from main dropped three parts of 86f6199b:
- `winsafe` from crates/engine/Cargo.toml (Cargo.lock still listed it, so every
  `--locked` CI step failed). It now sits in the engine's existing `cfg(windows)`
  dependency table next to `same-file`.
- the `require_windows_pdf()` call. Main moved `craft_print::submit` into
  `spool_job`, so the check now runs there, right before the Windows spooler
  route; that is the only call that reaches winprint's `WinPdfPrinter`
  (Windows.Data.Pdf.dll). The native RGB route, IPP, the app spooler and
  Save as PDF don't need the DLL and are unchanged.
- the Windows load test and the no-op test for other platforms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:25:22 -04:00
skylar taylor-barrick 6cec0cab24 Save: a read-only file is refused, so its metadata test asserts that
main's 589941a5 (#6600) refuses to replace a read-only file before anything
is written. This branch's test wrote to a 0o444 file and expected the save to
succeed, so the merge of the two failed on Linux:

    cannot save .../doc.psd: the file is read-only; the original file was
    not changed

No bug on either side: the two changes disagreed about what a read-only save
should do, and main's answer is the deliberate one. The test now asserts the
refusal, that the original is untouched, and that the attribute and mode survive
— which is what it set out to check. The writable cases alongside it still cover
the metadata copy itself.

The windows-latest failure was the roll-up gate reporting this same failure, not
a second one.
2026-10-18 16:24:03 -06:00
Higgs32584 ac5611bc80 Merge remote-tracking branch 'upstream/main' into fix/5884-delay-load-windows-data-pdf 2026-10-18 18:22:11 -04:00
c6fd71241d docs: add user docs in docs/user (#3951)
* docs: add user docs for PhotoCraft

Adds docs/user/ (published at getartcraft.com/docs/photocraft), the docs
check and publish workflows (copied from craft-launcher), and a User docs
rule in AGENTS.md. Checker: 0 error(s), 0 warning(s).

Source notes per page:
- index.md: README (intro, status, Crafting Apps table), website
  crafting-apps.ts photocraft entry, links.rs (Discord, issues), menus.rs
  (Help > Report an Issue).
- getting-started/index.md: menus.rs and menu_catalog.rs labels, panels.rs
  (Layers, Properties, History), dialogs.rs (Create button).
- getting-started/install.md: README Downloads and Get started sections,
  gh release view v0.7.0 asset list, craft-launcher catalog.rs (lists
  PhotoCraft).
- guides/open-and-save-files.md: apps/photocraft/src/services.rs (open and
  save filters), README Formats and PSD sections, docs/file-format-parity.md.
- guides/adjust-colors-with-adjustment-layers.md: menu_catalog.rs, panels.rs
  (footer button, adjustment list), lib.rs (Properties shows new layer).
- guides/select-and-mask.md: menu_catalog.rs Select and Layer Mask menus,
  state.rs tool names, retouch_ui.rs (Select Subject button and message).
- guides/add-and-style-text.md: state.rs (Horizontal Type Tool, T),
  type_tool.rs, menu_catalog.rs Layer Style and Type menus, dialogs.rs.
- guides/export-an-image.md: export_dialog.rs (labels, formats, options).
- troubleshooting/taskbar-turns-black.md: issues 3550, 3864, 3430, 3144,
  PR 3308 (git: not an ancestor of v0.7.0), prefs_ui.rs (System title bar).
- troubleshooting/text-moves-when-editing.md: issues 3795, 1317, PR 3819
  (open).
- troubleshooting/get-help.md: menus.rs, links.rs, gpu_status.rs (System
  Info content).
- faq/*: README (status, license badge, offline, formats), LICENSE-MIT and
  LICENSE-APACHE, docs/file-format-parity.md.

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

* docs(user): apply final review fixes

Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 4 items 1-9: remove x86 row and TGA resolution, formats, new photocraft-does-not-start page, mask label, taskbar workaround, wording, Affinity read-only, index.

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

* docs(user): use the standard section title

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

* docs(user): describe PhotoCraft 0.7.0 startup behaviour

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

* docs(user): tighten PhotoCraft startup wording

Drop the unsourced "slower" claim for CPU mode and give the exact
Preferences path on Windows and macOS.

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

* docs: pin the docs checker to a commit and its SHA-256

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

* docs: drop the docs workflows until the checker is on artcraft-services main

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

* docs: re-verify against 0.8.2

- taskbar and PSD text pages: both fixes ship in 0.8.2; tell users to
  update and drop the "not in a release yet" wording and workarounds
- does not start: 0.8.2 fixes the Intel Iris Pro/Monterey Device(Lost)
  case; OpenCore Legacy Patcher Macs still have no fix
- install: Windows 10 or later, 32-bit Windows builds, RISC-V deb/rpm,
  Wayland paste and XWayland notice
- open and save: Krita and Paint.NET filters, Krita/PCX/FITS formats,
  save progress, JPEG/WebP encoding kept on Save
- export: TGA Resolution, multi-layer Export As and Quick Export
- text: Shift-click starts new text over existing type
- CLAUDE.md is now a regular file pointing to AGENTS.md
- verified: 0.8.2 on every page

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

* Drop the root AGENTS.md docs rule; keep the PR to docs/user/

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

* docs(user): drop the stale missing-tools count from the Photoshop FAQ

Re-verified the page against v0.9.0.

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

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:15:53 -06:00
Korg1ch f3fb5781ef Lens Blur: an alpha channel as the depth map source, as in Photoshop (#7400) (#7508)
* fix(filters): Lens Blur reads its depth from an alpha channel, listed in Depth Map › Source as in Photoshop (#7400)

* test(filters): Lens Blur depth from an alpha channel by name or index, its errors, and the Source menu (#7400)
2026-10-18 17:23:32 -04:00
Brian TandClaude Opus 5.5 ca69059dd0 Edit: Copy with no selection keeps fill and adjustment layers (#2344) (#7497)
Copy refused fill and adjustment layers ("the active layer has no
pixels"). With no pixel selection they are now taken whole, like smart
objects, type, shape and group layers, and Paste recreates them in this
or another document. An adjustment layer's clip spans the canvas, so
paste placement and the OS-clipboard mirror stay consistent.

Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated;
the row stays partial (a single pixel layer still copies pixels only).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 17:23:26 -04:00
Nguyễn Minh Long 72a3423a7e Preferences: Enable Gestures off ignores touch-screen pinch and twist (#204) (#7478)
Edit > Preferences > Enhanced Controls > Enable Gestures (enhancedControls.touchGestures)
was saved but read by nothing, so it was hidden from the dialog. Now, with it off, two
fingers on a touch screen no longer zoom the canvas or the Navigator, nor turn the view.
The mouse and trackpad behave as before, including a trackpad pinch or Rotate event that
arrives while fingers rest on the screen.

- wheel_nav: pinch_factor() drops egui's multi-touch zoom when gestures are off and keeps
  the trackpad's own Event::Zoom (Windows' folded Ctrl + wheel pinch included);
  read_with() takes the preference.
- rotate_view: trackpad_delta() only falls back to the multi-touch rotation while gestures
  are on; Rotate events always count.
- canvas, Navigator: pass the preference through.
- prefs: touchGestures leaves HIDDEN_UNTIL_IMPLEMENTED, so the dialog shows it.
- Tests: synthetic two-finger pinch and twist, on and off, and the trackpad events with
  gestures off.
- docs/scorecard.md regenerated: settings that do nothing 26 -> 25 of 168.
2026-10-18 17:23:21 -04:00
Marco Arnulfo 8736098d38 Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (#7473)
* Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (part of #7325)

With Image > Adjustments > Curves open, a Cmd-click (Ctrl-click off the Mac) on
the image adds a point to the channel shown in the dialog, at the pixel's tone
and at the curve's current output there; Shift adds one point on each of Red,
Green and Blue. Only the press adds, an armed eyedropper still wins, and the
control channel's ui.pointer does the same with command: true.

* Curves: Cmd/Ctrl-click adds the point with the Hand tool too; Space and middle button still pan

The Hand tool took the Cmd-click press for a pan, so no point was added; the
press now counts before the Hand. On the control channel, ui.pointer with
space or the middle button pans as the mouse does instead of adding a point.

* Curves: the Cmd/Ctrl-click point's comment says where the curve can still change
2026-10-18 17:23:16 -04:00
Marco Arnulfo 0eb0387d53 Save for Web and Quick Export: "All" keeps the photo's EXIF (fixes #7436) (#7471) 2026-10-18 17:23:10 -04:00
JulienandClaude Opus 5.5 bbfeae98e4 Type: right-click on committed text opens the type layer menu (#7324) (#7430)
* Type: right-click on committed text opens the type layer menu (#7324)

With the Type tool, a right-click only opened a menu while text was being
edited; over committed type nothing opened. The menu was keyed on the edit
session alone (`editing_text`).

Over committed type, a Type tool (not a Type Mask tool) now opens
TYPE_LAYER_MENU: the type edit menu's layer rows, without the edit rows
(Undo, Cut, Copy, Paste, Select All), which would act on the document, and
without Faux Bold/Italic, which act on the characters being edited. Type
outside the selected layers becomes the target first, as a click with the
Type tool makes it. The control channel's right button takes the same path.

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

* Type layer menu: the clicked type becomes the active layer in a multiselection (#7324)

The structural rows (Rasterize Type, Convert to Shape, Create Work Path, the
paragraph/point conversions) act on the active layer. When the right-clicked
type was already selected but another selected type layer was active, they
acted on that other layer. Make the clicked type the active layer while
keeping the selection (layer_multi_cmds::set_selection), so the formatting
rows still act on every selected type layer. Found by CodeRabbit on #7430.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 17:23:05 -04:00
skylar taylor-barrick 915779b948 Merge storytold/main into save-replace-metadata
No textual conflicts, but the merge did not compile. main's 589941a5 added a
second AtomicFs test impl using copy_permissions, while this branch renames that
trait method to copy_metadata. Git merged both cleanly because they sit in
different parts of the file, giving a tree whose trait and impl disagree:
E0407, E0046 and E0599 in photocraft-format's lib test. That one error is what
failed all four CI jobs.

main's new impl is renamed to copy_metadata to match.

Verified with --features heif, which is what CI builds with and what I had
previously failed to pass locally: clippy --workspace --all-targets --locked
--features heif -- -D warnings is clean, and the workspace tests pass.
2026-10-18 15:12:41 -06:00
Korg1ch f8e4a1344f Print: the last print's settings survive a relaunch, as in Photoshop (#7410) (#7526)
* fix(print): the last print's settings are kept in the preferences, so Print opens with them after a relaunch (#7410)

* test(print): the last print's settings survive a relaunch for Print and Print One Copy (#7410)
2026-10-18 16:57:24 -04:00
NetterSilverandClaude Haiku 5.5 92721dd8c4 Refuse strict .atn export of plain events whose target is not the layer pixels (#7564)
Fixes #7272

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 16:57:19 -04:00
David MannandClaude Opus 5.5 5225b592fe Perspective Crop: draw the crop quad's side of the horizon (#7557) (#7558)
When the crop quad's horizon crossed the layer and most of the layer
(or its centre) lay beyond it, warp picked the source's majority side
and skipped every output pixel, so the crop came out blank. A clip
wholly on one side of the inverse horizon only ever samples that side,
so draw it; a straddling clip keeps the majority rule (#4863).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:57:14 -04:00
JGF b48a916b13 fix(algo): normalize nonpositive filter tile sizes (#7554) 2026-10-18 16:57:08 -04:00
David MannandClaude Opus 5.5 d77a989ef4 image.crop: accept whole-number float targetWidth/targetHeight (#7551) (#7552)
The target size was read with as_u64 only, so 100.0 (json.dumps of a
float, many agent clients) was refused as not a whole number, while the
crop rectangle and image.imageSize accept it. Fractional, negative,
zero and out-of-range values are still refused.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:57:03 -04:00
Alexis 449b99a74e fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY (#7521)
* fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY

The positional match for , and . (so Shift+Cmd+. works on Nordic/German layouts)
applied to every shortcut. On AZERTY, ; sits on the US comma key, so ; fired the
command bound to , (brush hardness up/down did the same thing). Only shortcuts
with Cmd now follow the key position.

* contributors: credit Byxis by their GitHub profile name
2026-10-18 16:56:58 -04:00
Khawar Zamman WaniandClaude Opus 5.5 5095babf89 perf(canvas): composite and upload an inline filter preview once (#7490) (#7501)
The synchronous filter-preview path (web, and sessions with background
jobs off) flattened each changed preview, uploaded it and recorded it,
then flattened it again for publish_filter_preview, which uploaded and
recorded it a second time. Flatten once, as the background worker does,
and let publish_filter_preview do the upload and the perf record.

This removes one full composite and one texture upload per changed
preview, and the frame's refresh time no longer counts the preview twice.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:53 -04:00
NetterSilverandClaude Haiku 5.5 0807441e52 Make .pcraft reload f64 coordinates bit-exact (#7498)
Found while testing crates/format/Cargo.toml:19 (serde_json features), used for the manifest in crates/format/src/manifest.rs and store.rs

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 16:56:48 -04:00
59c8bd3b26 Fix/7381 action worker failure 20261018t2254jkt (#7465)
* fix(actions): stop resumed playback when a dialog's worker fails

* test(actions): reject suffix after failing background dialog filter

* fix(actions): propagate the failed worker result through dialog confirmation

* fix(dialogs): return action worker failures to callers

* Collapse the nested if in actions::resume (clippy::collapsible_if)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:44 -04:00
fdd4ca7cbc Remove: cancel queued strokes when the paint target changes (#7380) (#7464)
* fix(remove): cancel waiting strokes when their target changes

* fix(remove): store pending target identity with waiting strokes

* test(remove): cover queued strokes across layer and channel switches

* Tests: format retouch_ui.rs and drop the unreachable! arm clippy denies

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:39 -04:00
8ea77bdfd6 Fade: restore uniformly inverted layer masks (#7383) (#7463)
* fix(fade): detect uniform mask changes and blend full canvas

* test(fade): cover uniform mask inversion and Fade across bit depths

* style(fade): keep default-pixel comparison readable

* Format edit_menu_cmds.rs with rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:33 -04:00
Hodd 140230b141 Replacing selected type keeps its size, as in Photoshop (#7457)
Typing or pasting over a selection now takes the style of the first selected character instead of the one before it, so retyping a smaller second line no longer takes the first line's size from the line break. Fixes #7413.
2026-10-18 16:56:26 -04:00
Khawar Zamman WaniandClaude Opus 5.5 502d055c6c file.saveACopy refuses the document's own file without "overwrite" (#7437) (#7456)
* file.saveACopy refuses the document's own file without "overwrite" (#7437)

Save a Copy through the engine (control channel, MCP, serve, headless
action playback) silently replaced the open document's own file when
given its path, so a lossy re-encode could not be undone. It now returns
BadParams when `path` is the active document's file (same text, ./ or ..
spelling, hard link, case on Windows via same_file) unless the params
pass "overwrite": true, and nothing is written. The desktop dialog writes
through the shell after the OS confirms a replace, so it is unchanged.

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

* 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]>

* saveACopy own-file test: hard link on every platform, doc state unchanged after a refusal

Each refused copy (same path, ./ spelling, hard link) now also checks the
document keeps its path, revision and unsaved state. The hard link is no
longer unix-only: same_file compares file identity on Windows too. (Review
on #7456.)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:21 -04:00
driftee ecc1a9e47b fix(transform): detach clipped preview ancestors (#7438) 2026-10-18 16:56:15 -04:00
Korg1ch a4436074c9 Font field: ↑/↓ step through the fonts after a pick, as in Photoshop (#7407) (#7426)
* fix(type): ↑/↓ on the focused font field step through the families after a pick, as in Photoshop (#7407)

* test(type): the font field keeps the focus after a pick and ↑/↓ step the families (#7407)
2026-10-18 16:56:10 -04:00
Korg1ch 9b4648e195 Font menu: hovering a font no longer grows its row and makes the list jump (#7406) (#7425)
* fix(type): font menu rows keep their size when hovered or selected, so the list no longer jumps (#7406)

* test(type): hovering a font row moves no row and keeps the menu width (#7406)
2026-10-18 16:56:04 -04:00
.CooCoo 89b9e72640 Tests: the authentication deadline test writes its first line before the deadline starts (#7428)
an_authenticated_connection_outlives_the_authentication_deadline started
its 200 ms deadline after it spawned the thread that writes the first
line. When the OS started that thread late, the first read failed with
TimedOut at crates/automation/src/security.rs:447. On a loaded machine
the photocraft-automation lib tests failed 5 of 30 runs this way.

The test thread now writes the first line before the deadline starts,
and the limit is 1 s. The sender thread writes the second line after
the test thread has called authenticated() and 300 ms after the
deadline, so the second read still waits past the deadline. The test
still fails when authenticated() is not called. The product's
AUTH_DEADLINE is unchanged.
2026-10-18 16:55:59 -04:00
.CooCoo 617264a124 Tests: the slow-job frame loop test waits for the worker's progress message (#7424)
the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it
stepped frames until 400 ms of wall time and 10 frames had passed, and
then looked for the job's status message, "Filtering tiles", in the
progress dialog. The job's worker reports that message as its first
statement, on a thread of its own. The dialog appears after 250 ms
whether or not the worker has run, and it reads "Working…" until the
message arrives. On a loaded machine the worker can start after the
400 ms window, and the test then fails with "the job's status
message".

The test now steps at least 10 frames and until the dialog shows
"Filtering tiles", through a new helper, frames_until, which fails the
test when the condition does not hold within 60 s. It also checks that
the job is still running at that point. The helper has the same
signature as the frames_until in #3422.
2026-10-18 16:55:54 -04:00
Finn Newick 8626ad9ae0 Merge remote-tracking branch 'origin/main' into refactor/engine-drop-note-insert 2026-10-18 21:34:40 +01:00
aiongg 1c2193f562 Merge PR #7011: Artboards to Files: paths, notes, slices and marks move with the board (#7010) 2026-10-18 20:33:44 +00:00
aiongg b3b8c7b082 Merge PR #6809: Transform: drop a fill layer's cached render when its frame changes (#6808) 2026-10-18 20:33:44 +00:00
aiongg 74e6f54b38 Merge PR #6800: Mesh warp: about twice as fast, same pixels (tiles encoded in parallel, direct in-bounds reads) (#1015) 2026-10-18 20:33:44 +00:00
aiongg 4e517c054e Merge PR #6779: Image Size: a gradient fill's centre offset stays a fraction of the frame (#6778) 2026-10-18 20:33:44 +00:00
aiongg 978404424e Merge PR #6769: Presets: refuse renaming a group to a name another group has (#6768) 2026-10-18 20:33:43 +00:00
aiongg 5caeea8c1b Merge PR #6670: fix(engine): refuse a Content-Aware Fill with nothing to sample instead of filling black (#6532) 2026-10-18 20:33:43 +00:00
aiongg 5235544baf Merge PR #6256: Menu bar: zooming with Alt+scroll, then holding Space to pan, no longer opens New Document 2026-10-18 20:33:43 +00:00
aiongg fdb3d97031 Merge PR #5983: Codecs: cap what tiff_exif::read copies from overlapping EXIF values (#5982) 2026-10-18 20:33:43 +00:00
aiongg fc990e5818 Merge PR #5726: Brush smoothing: the lagging brush keeps the pressure of where it is (#5713) 2026-10-18 20:33:43 +00:00
aiongg 34e5b4b42f Merge PR #5658: Video: Interpret Footage and New Video Layer from File reject an invalid frame rate (#5657) 2026-10-18 20:33:43 +00:00
aiongg baee303af1 Merge PR #5656: Image > Adjustments: locked smart objects refuse new smart filters (#5655) 2026-10-18 20:33:43 +00:00
aiongg fd44b1c873 Merge PR #5620: Smart Filters: deleting the last filter removes its filter mask (#5619) 2026-10-18 20:33:43 +00:00
skylar taylor-barrick 92c4457b16 Preferences: the settings fill the resized dialog, buttons at the bottom (#7420) (#7462)
* Preferences: the settings fill the resized dialog, buttons at the bottom (#7420)

#5170 made Preferences resizable, but its contents still sized themselves
from the app window (width capped at 1040, settings area at 680 tall), and
the dialog opened at a 960x900 default taller than that. So it opened with
empty space below the buttons, and enlarging it left the settings and the
buttons stuck in the top-left corner.

The settings now take their width and height from the resizable area,
leaving room for the button row, and Preferences opens at 1040x800. The
settings area stays a few points short of the space it is given: egui grows
the area to fit its contents, so asking for more would make the dialog grow
every frame.

* Preferences: a rendered test drags the resize grip and checks the buttons stay in the corner (#7420)

Opens Preferences in a kittest harness, drags its grip wider and then smaller, and checks the button row stays in the frame's bottom-right corner each time and that the dialog keeps its size afterwards. Fails with the sizing from before the fix.

* Preferences resize test: the buttons must also stay inside the frame

A button row past the frame's bottom or right edge gave a negative gap and passed; now each gap must be 0 to 24 pt.
2026-10-18 14:33:13 -06:00
Finn Newick f1b1896d20 Merge remote-tracking branch 'origin/main' into refactor/engine-drop-note-insert 2026-10-18 21:21:25 +01:00
Finn Newick 098337b48a Merge remote-tracking branch 'origin/main' into upd/psd-packbits-decode-into 2026-10-18 21:20:09 +01:00
David MannandClaude Opus 5.5 37a4491c63 Merge main into perf/p25-blur-cancel
Conflict in crates/engine/src/filters.rs: main made keep_alpha pub(crate) for the
plug-in runner (#6770); kept the PR's parallel, cancellable keep_alpha with that
visibility, and the plug-in caller now passes a never-cancelled check and propagates
the Result.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:57:28 +02:00
David MannandClaude Opus 5.5 4bf11b1293 Merge origin/main into fix/fill-layer-angle-finite
Resolved commands.rs: keep main's finite-checked color_param(cmd, ...)
and this branch's angle_param for the gradient fill angle; drop the
now-unused f32_or.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:51:27 +02:00
David MannandClaude Opus 5.5 7835a027e3 Merge main into fix/tiff-filter-effects-sections
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:45:14 +02:00
David MannandClaude Opus 5.5 42877b18e4 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:39:36 +02:00
David MannandClaude Opus 5.5 c82137d418 Merge main into fix/crop-straighten-box-undo
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 21:26:41 +02:00
Akos Arinton 33dd5f0103 perf(gpu): blend plain layers several to a pass, P60/P61 111 -> 28 ms
- LayerBlend: a plain layer is blended straight from its texture in one pass
  instead of a Content pass to a scratch chunk and a Blend pass.
- fs_layerbatch: runs of up to four plain layers that only feed each other
  are drawn as one pass, reading and writing the chunk once (the uniform
  record grows to its 256-byte stride to hold the extra layers).
- Drop shadows and outer glows alone: the merge builds the layer from its
  shape (F_LAYER_SHAPE), and nothing is copied back when the effect region
  covers the canvas. Such a layer takes 3 passes instead of 6.
- fs_layerbatch_normal: runs of Normal layers use a shader with Normal's code
  alone. Per-pass timestamps showed the batch spent 80% of its time on the
  register cost of carrying every blend mode, not on the arithmetic; Normal
  goes through the same function in every shader, so a layer gives the same
  bits whichever pass draws it.

P60 / P61 with the GPU compositor (8 GB budget, M4 Pro): 111 / 113 ms ->
28.2 / 28.1 ms. With the app's default Memory Usage these documents still
fall back to the CPU compositor on unified memory, so the budgets stay
unenforced.

Output: over the GPU parity suite (1620 renders) 1038 are bit-identical to
before and the rest differ by at most 1e-4/255, except one pixel one 8-bit
step apart where an adjustment rounds at .5; the mean difference from the CPU
reference is unchanged. New parity tests cover runs of every mode, Normal
runs at 8/16/32-bit and Lab, and exterior effects at any fill.
2026-10-18 22:26:06 +03:00
Akos Arinton 5418399dda perf(compose): blend pixel layers row by row, Normal in 8-pixel vector blocks
A pixel layer with nothing clipped to it is read from its tiles a row at a
time and blended straight onto the backdrop, instead of being copied into a
tile-sized buffer first (a megabyte per layer per 256² render tile). Normal
RGB rows go through a branch-free kernel over blocks of 8 pixels that the
compiler vectorizes; few-band renders also run their bands in parallel.

Bit for bit the same: the row kernel is tested against the scalar loop (NaN,
infinities, subnormals, signed zeros), the row path against the tile copy at
8/16/32-bit with every kind of mode, fill and mask, and the whole GPU parity
suite's CPU references (1620 renders) are unchanged.

P60 on the CPU compositor: 627 -> 530 ms (the render without effects
300 -> 199 ms).
2026-10-18 22:26:05 +03:00
David MannandClaude Opus 5.5 43a718ffab Merge main into fix/crop-target-size-samplers
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 21:25:20 +02:00
jonathnwxh 99e828b9f7 Merge upstream and retain selection and arranged-view regressions 2026-10-19 03:05:19 +08:00
Higgs32584andClaude Opus 5.5 b3bee7496e Batch Same format: an input without an extension is saved as PNG
same_format took everything after the last dot of the whole path, so a file name without an
extension (or a dot in a folder name) gave a bogus output extension and the documented PNG
fallback never ran. It now uses file_cmds::extension on the file name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:16 -04:00
Dylan M. Taylor 050c8ebed8 docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:45 -04:00
Dylan M. Taylor 9185b64b94 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:31:45 -06:00
aiongg 56dbc89a38 Merge pull request #7477 from aiongg/fix/shared-dock-color-fill
Color panel field fills its group in a custom workspace too
2026-10-18 13:36:50 -04:00
aiongg fa99e183b6 Color panel field fills its group in a custom workspace too
#2455 stretched the pro Color panel's SV field and hue strip to the dock
group's height, but only the legacy dock told the field that height. The
shared docking renderer from #3097 drew the panel without it, so once the
layout was customized the field fell back to 120 pt. Both docks now offer
the group body's height through one helper.
2026-10-18 17:04:55 +00:00
skylar taylor-barrick 5c7c847923 perf(canvas): report how long the GPU spends on a composite (#7275)
`composite_ms` measures encoding and submitting a composite, not running it: on
a 24 MP refresh it read 7.7 ms for work the GPU spent ~171 ms on, so nothing in
the app could see a regression that moved work onto the GPU.

A timestamp is written on the GPU timeline at the start and end of every command
encoder a refresh submits, and the spans are summed and read back
asynchronously. Nothing waits, so a measurement arrives a frame or two later and
carries the pixel count of the refresh it belongs to. It is reported as a new
`Option<f64>` `perf.gpu_ms`; `composite_ms` keeps its meaning, so its consumers
are untouched. At 24 MP it reports 83.9 ms against a waited wall clock of
88.4 ms, where the app previously said 3.1 ms.

What it leaves out, documented in the module: queue-level uploads, which wgpu
runs in its own command buffer ahead of ours, and the end-of-batch writeback
after the last timestamp. That is ~5 % of the reported time here and
proportional to the composite, against the ~22x of the number it replaces.

Unavailable rather than wrong: without TIMESTAMP_QUERY and
TIMESTAMP_QUERY_INSIDE_ENCODERS the timer is never built and `gpu_ms` is None —
on older drivers, the CPU compositor, wasm, and Apple silicon, whose GPUs
support counter sampling only at stage boundaries. `PHOTOCRAFT_NO_GPU_TIMING`
suppresses both the timer and the feature request.

Pass-boundary timestamps would cover Apple silicon and need only
TIMESTAMP_QUERY, but they capture 70-78 % of the encoder-level span here and
the shortfall grows with document size, so they were measured and rejected.

D3D12 is exercised: device creation, a 100 ns/tick period and the full
write/resolve/map round-trip succeed with no validation errors, including
across a flushed three-buffer refresh. Numbers throughout are from an
integrated Intel GPU on Mesa/Vulkan and a software D3D12 adapter; no vendor
Windows driver and no Apple hardware was tested.
2026-10-18 10:41:35 -06:00
aiongg 5c89f16b60 Merge pull request #7432 from aiongg/integration-2
Integration batch: 11 PRs on main
2026-10-18 11:41:49 -04:00
aiongg a07b87a1af Merge upstream main into integration-2
crates/engine/src/mode_cmds/tests.rs: keeps #7139's opaque-background test and main's full-precision angle test
2026-10-18 15:11:28 +00:00
aiongg 4df773e966 Merge pull request #7405 from aiongg/next
Integration batch: 19 PRs on main
2026-10-18 11:09:40 -04:00
Dylan M. Taylor 18f8d33577 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:00:42 -04:00
7066497886 Keep fractional translation-only Free Transform on .atn import (#7401)
Fixes #7298

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 10:51:21 -04:00
UnemarsandClaude Fable 5.1 491af6d42c Camera Raw histogram: spikes run off the top, as in Photoshop's Camera Raw (#7419)
The dialog's histogram fitted its tallest interior column to the plot, so one spike (a dark sky, a bright wall) flattened the midtones' hill to a few pixels. Camera Raw 16.1.1 scales linearly by the typical columns and lets a spike run off the top (measured on Photoshop 25.4: 49 px per 10 000 pixels in a 106 px plot, the 95th-percentile column at 14 546, the tallest at 21 600).

`rgb_histogram::Spikes`: `Fit` (the old behaviour, every other histogram keeps it) and `Clip` (the scale is at most 1.5 × the 95th-percentile column, taller columns clip at the top). The Camera Raw dialog's histogram uses `Clip`.

Test: `camera_raw_scale_lets_a_shadow_spike_run_off_the_top`.

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

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-18 10:50:47 -04:00
UnemarsandClaude Fable 5.1 65778c1f7e Raw tone curve: no NaN between a knot at black and the next one (#7416)
`develop::tone` interpolates the camera curve logarithmically between knots, and a curve whose first knot sits at black (0, 0) made the first segment divide by log₂ 0: every value in it came out NaN, and `retone` (the open-time Shadows/Highlights preview) propagated it into the pixels. From a knot at black the segment is now linear, where log₂ has no value; the other segments are unchanged.

Test: `retone_lifts_shadows_with_a_curve_from_black` (finite output, shadows lifted, highlights kept) fails without the fix.

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

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-18 10:50:41 -04:00
NetterSilverandClaude Haiku 5.5 713dbbed2f Save Actions ATN files atomically so a failed write keeps the old file (#7403)
Fixes #7290

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 10:50:37 -04:00
NetterSilverandClaude Haiku 5.5 80553ecd93 Refuse strict ATN export of Close with an explicit document target (#7393)
Fixes #7299

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 10:50:32 -04:00
David MannandClaude Opus 5.5 89fa322a3c Crop: Esc and Enter go to an open options bar popup first (#7386) (#7388)
The shortcut pass runs before the panels are drawn, and its crop/polygon
branch consumed Esc and Enter even while an egui popup of the options bar
was open (the Ratio list, the overlay and gear menus): Esc cancelled the
crop and left the list open, Enter committed the crop beneath it. Skip the
branch while `egui::Popup::is_any_open` (kept in memory from the last
frame; `ctx.any_popup_open` only sees the current pass).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:50:27 -04:00
Khawar Zamman WaniandClaude Opus 5.5 4b2bee675e Spot Healing Brush trail is Photoshop's dark half-opaque tint (#7342) (#7374)
The stroke preview was white at a quarter opacity, invisible on white
and faint elsewhere. Photoshop shows the area to heal in half-opaque
dark grey; the trail now does too. Other retouch trails are unchanged.

Test: drag_preview_canvas::the_spot_healing_trail_is_a_dark_half_opaque_tint
drags the real canvas offscreen on white and mid-grey and checks the
trail darkens the pixels to 40-60 %; it fails with the old colour.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:39 -04:00
David MannandClaude Opus 5.5 d459ca7939 Image Rotation: Arbitrary keeps Color Sampler points on their pixel (#7372) (#7373)
Sampler points are pixel indices, but rotate_arbitrary mapped them as the
pixel's top-left corner while the warp moves pixels by their centres, so
after a turn a point sat one pixel off its pixel, or fell off the canvas
(a corner point at 90° or 180°). Map the pixel centre, as #6720 does for
flips and right-angle turns; the turned Crop shares this map.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:34 -04:00
David MannandClaude Opus 5.5 a4c7b5ef89 Rotate Canvas Arbitrary: keep the angle at full precision, so rotated crops land on the pixel (#7369) (#7370)
rotate_arbitrary read `angle` as f32. rotated_crop sizes its canvas with
rotated_size at f64 and passes the angle through JSON; near a whole-pixel
ceil boundary the f32 angle grew the canvas 1 px more (1000x1000 at
0.11470651449337071: 1003 instead of 1002), shifting the crop by 1 px.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:29 -04:00
David MannandClaude Opus 5.5 fb6f8b4fa6 Canvas Size: growing the canvas re-renders PSD fill layers (#7362) (#7363)
A fill layer from a PSD keeps its stored pixels as a fill cache that covers
only the old canvas. Canvas Size, an extending Crop and Reveal All translated
that cache but kept it, so the added area of the fill layer stayed
transparent. Drop the fill caches whenever the canvas grows, so the fill
re-renders from its model over the whole canvas, as Photoshop does (Free
Transform and Perspective Crop already drop it).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:24 -04:00
David MannandClaude Opus 5.5 06d4d31dd4 Tool Presets: + saves only the current tool's options (#7356) (#7357)
The + button in Window › Tool Presets stored the whole ToolOptions struct, so choosing
the preset later wrote every other tool's options back (e.g. Crop's Delete Cropped
Pixels). Reset Tool's per-tool field lists become `option_keys`, which the + button now
uses to store only the tool's own options. Brush/Pencil Clear, the Eraser's mode, the
Remove tool's options and Content-Aware Move's Transform On Drop join their tools' lists,
and a test checks that every option belongs to a tool.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:19 -04:00
David MannandClaude Opus 5.5 5f648126c7 Crop and Straighten Photos keeps Duotone inks and is disabled for Indexed Color (#7354) (#7355)
The copies were made with Document::new and kept the mode but not the
duotone inks, so a Duotone scan came out as plain gray. Copy them, and
grey the command out for Indexed Color documents, as Photoshop does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:15 -04:00
MarekSpodBiedry c8c651c413 Esc leaves full screen with the Crop tool showing its default frame (#7315) (#7350) 2026-10-18 10:39:09 -04:00
MarekSpodBiedry e04f522667 Centre stacked ruler digits and the Properties W/H/X/Y labels (#7314) (#7349) 2026-10-18 10:39:04 -04:00
MarekSpodBiedry 2c1c6f6693 Open Photoshop guides stored in a JPEG's APP13 resources (#7300) (#7348) 2026-10-18 10:38:59 -04:00
driftee 6e460aab8a fix(transform): isolate nested preview siblings (#7280) 2026-10-18 10:38:54 -04:00
Brandon ThomasandClaude Opus 5.5 5b087699be Tests: fix the flaky file-lock tests and the occasional portal_save hang (#7418)
The rotation and instance-log tests in logging.rs, and two app_dirs migration-lock tests, drop a
flock and check straight away that it is free. A child that another test spawns at that moment
holds a copy of every open descriptor until its exec, the lock included, so the check sometimes
saw it still held (about 6 failures in 15 runs of the app's test binary). Tests that spawn
children (the portal mock's dbus-daemon, the display-profile helper test) now hold CHILD_SPAWNS
for reading while they spawn, and the lock tests hold it for writing.

The portal mock made its message stream inside its thread; a stream only gets messages that
arrive after it exists, so a SaveFile sent before the thread got going was lost and the test
waited forever (about 1 run in 25). The stream is now made before the thread starts.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:29:07 -04:00
aiongg 5a01650652 Integration: cargo fmt 2026-10-18 14:19:01 +00:00
aiongg 0b167ca259 Integration: #6476's uppercase token test writes its file owner-only (#5459 on main) 2026-10-18 14:19:01 +00:00
aiongg fac74f83e8 Integration: #6414 an unconfirmed save releases the unsaved-changes prompt 2026-10-18 14:19:01 +00:00
aiongg 0c36fbc49d Integration: #6035 move the new tests above existing tests' doc comments 2026-10-18 14:19:01 +00:00
aiongg 432c973033 Merge PR #6030: Help › Search: typed letters go to the field, not to menu mnemonics (fixes #5996) 2026-10-18 14:19:01 +00:00
aiongg 8f1eedfc36 Merge PR #6039: Window › Arrange: tiles keep their documents, and every tile has rulers (fixes #6017) 2026-10-18 14:19:00 +00:00
aiongg e7d4ce5e18 Merge PR #6364: Path Selection: the canvas right-click opens the path menu, as the Pen's does (#1056) 2026-10-18 14:19:00 +00:00
aiongg ccb62fcd8f Merge PR #6038: Type: Korean paragraph text wraps between words, not syllables (fixes #5959) 2026-10-18 14:18:59 +00:00
aiongg 9692eea083 Merge PR #6346: File › Open: a file type filter per format (fixes #6337) 2026-10-18 14:18:59 +00:00
aiongg 17e3490142 Merge PR #6035: Image Size: Indexed Color resamples with Nearest Neighbor only (fixes #6019) 2026-10-18 14:18:58 +00:00
aiongg b9b4a73cc0 Merge PR #6338: Smart filters: an edited filter's dialog previews again (fixes #6324) 2026-10-18 14:18:58 +00:00
aiongg dad0881247 Merge PR #6366: Type: the text caret shows on a white background (part of #6085) 2026-10-18 14:18:57 +00:00
aiongg eb2b1517a8 Merge PR #6505: Character and Paragraph panels: size, leading, baseline shift and indents follow the Type units preference 2026-10-18 14:18:57 +00:00
aiongg b5fed83c79 Merge PR #6335: Replace Color: the sample eyedropper is armed when the dialog opens (fixes #6279) 2026-10-18 14:18:56 +00:00
aiongg c5c68d5de7 Merge PR #6344: Replace Color: a Result swatch whose Color Picker sets the sliders (fixes #6278) 2026-10-18 14:18:56 +00:00
aiongg fb678d7463 Merge PR #6476: Automation: a control token authenticates in either letter case (#6425) 2026-10-18 14:18:55 +00:00
Michael Higgins cd3ef1444f Merge branch 'main' into fix/5799-batch-same-format-raw 2026-10-18 10:16:37 -04:00
Michael Higgins 49e1ae898b Merge branch 'main' into fix/4845-autohide-taskbar 2026-10-18 10:15:00 -04:00
Michael Higgins d7760fd7c4 Merge branch 'main' into fix/6884-pen-tool 2026-10-18 10:14:26 -04:00
Michael Higgins e8e745b7c2 Merge branch 'main' into fix/txt2-edited-object-6968 2026-10-18 10:14:21 -04:00
Michael Higgins 62f94d3f9f Merge branch 'storytold:main' into fix/5713-smoothing-pressure-follows-brush 2026-10-18 10:10:31 -04:00
Michael Higgins 43ad6f2990 Merge branch 'storytold:main' into fix/5884-delay-load-windows-data-pdf 2026-10-18 09:44:31 -04:00
aiongg 19e3292c9f Merge PR #7141: perf(camera-raw): Shadows/Highlights 1.5× faster, bit for bit the same 2026-10-18 13:23:03 +00:00
aiongg 811abbc3e9 Merge PR #6333: Help › Search: ← / → and Space edit the field instead of driving the menu (#6332) 2026-10-18 13:23:02 +00:00
aiongg 94bb46f5cc Merge PR #6834: Object Selection: enclosed background gaps stay unselected, only object-like holes fill (#6556) 2026-10-18 13:23:02 +00:00
aiongg d5b9de755a Merge PR #6814: Re-saving a Photoshop 16-bit PSB no longer adds a second Transparency channel 2026-10-18 13:23:01 +00:00
aiongg 2437fc36c5 Merge PR #6046: fix(path): validate Make Selection options and guard selection state 2026-10-18 13:23:00 +00:00
aiongg 58d8d911a2 Merge PR #6003: Count: reject unknown groups instead of adding markers to the wrong group 2026-10-18 13:23:00 +00:00
aiongg eef436de45 Merge PR #5654: Count tool: deleting an earlier count group keeps the active group (#5653) 2026-10-18 13:22:59 +00:00
aiongg 785b9478d7 Merge PR #6771: Plug-ins: running a filter plug-in respects layer locks (#6770) 2026-10-18 13:22:59 +00:00
aiongg da96d71506 Merge PR #6863: Native format: refuse a .pcraft with out-of-range numbers when it is opened (#6862) 2026-10-18 13:22:58 +00:00
aiongg 45c5f41af0 Merge PR #7046: Render Video: GIF frame delay follows timelines under 1 fps (#7045) 2026-10-18 13:22:58 +00:00
aiongg c9ac35b3b1 Merge PR #7139: Perspective Crop and Rotate Arbitrary keep the Background opaque (#7138) 2026-10-18 13:22:57 +00:00
aiongg 71a5bc44be Merge PR #6499: Desktop: a control/automation flag without its value is a usage error (#6429)
apps/photocraft/src/main.rs: #6499's parse_launch_args is the one parser; it also takes main's --help/-h, -- and unknown-option errors (#6435); usage_exit_code is the one exit helper
2026-10-18 13:19:41 +00:00
aiongg e452f6ad80 Merge PR #6414: Web: a requested download no longer marks the document saved (#4962)
crates/ui-egui/src/lib.rs: Services keeps both main's file_stamp and #6414's write_unconfirmed
2026-10-18 13:16:00 +00:00
aiongg cab62b9516 Merge PR #6399: Brush: the Dual Brush tip keeps its proportion at tiny main sizes (#4809) 2026-10-18 13:15:52 +00:00
aiongg 2c8314165e Merge PR #6506: Refuse Affinity spot colours nested more than 4 levels deep 2026-10-18 13:15:51 +00:00
aiongg d072538dae Merge PR #5992: Retouch: bound every stroke command before its dense buffers (#4961, #6110) 2026-10-18 13:15:51 +00:00
aiongg 4127604122 Merge PR #6502: Engine: reject numeric overflow before document edits 2026-10-18 13:15:50 +00:00
aiongg 5676d7c5b0 Merge PR #5671: fix(svg): budget rasterised fallback layers across imports 2026-10-18 13:15:50 +00:00
Higgs32584andClaude Opus 5.5 98f4c9e1b8 test(psd): write the NUL in psd16_merged_transparency.rs as an escape
The test file held a literal NUL byte, so git treated it as binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 08:54:20 -04:00
PerchenkoAntonandBrandon Thomas 8d2e615ab9 i18n(uk): translate color picker titles and rasterization warnings (#6153)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:50:35 -04:00
MarekSpodBiedryandBrandon Thomas c39d752758 Translate the brush size and hardness shortcut rows in every language (#6742) (#6831)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:48:59 -04:00
Marco ArnulfoandBrandon Thomas d6ea04299f Document tabs: keep whole names, hide what doesn't fit, list every document in the » menu (#4525)
* Document tabs: keep whole names, hide what doesn't fit, list every document in the » menu (fixes #4487)

* Document tabs: check the active document in the » menu (#4487)

Like Photoshop's, the » menu marks the active document with a check mark,
drawn as the menu bar's menus draw theirs.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:48:54 -04:00
dotCooCoo 99031545b2 Merge main into fix/abr-repeated-brush-names 2026-10-18 00:48:20 -07:00
cc772ad9dc Saving onto a dangling symlink writes through to its target (#6605) (#6649)
* fix(format): saving onto a dangling symlink writes through to its target (#6605)

resolve_symlink fell back to the link path when canonicalize failed, so an
atomic save renamed over the symlink and replaced it with a regular file.
Dangling chains now resolve lexically (relative targets included, with a
depth cap), so the bytes land at the target and the link survives; a
symlink loop is a clear error instead of a silent link replacement.

* Follow dangling links without folding .. by text

A folder on the way may itself be a symlink, so leave .. for the OS to resolve.

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

---------

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:35:35 -04:00
wwew 04967b12f9 docs: write product name as PhotoCraft in engine module doc, LUT export header and image sources (#5454) 2026-10-18 03:35:28 -04:00
JunHyun ParkandClaude Opus 5.5 386368956f Korean: consistent terms in the Gradient Editor, Object Selection messages and shortcut hints (#6882)
Eighteen labels in ko.tsv used a different word than the rest of the
Korean catalog for the same term, or named a menu item by a label the
menu does not use (Reset All to Defaults reads 모두 기본값으로 초기화).
Only translation values change; placeholders and ellipses are untouched.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:35:22 -04:00
Tom InglingandClaude Opus 5.5 b3c8a2a7f3 Assign Profile and Convert to Profile: Preview, as in Photoshop; Black Point Compensation spelled out (#6825)
- Both dialogs get the Preview checkbox (`PREVIEWED`).
- The canvas shows a filter preview through the preview's own colour management (its profile and
  mode), not the committed document's: Assign Profile changes only the profile, so its preview
  looked unchanged. The preview keeps the document's id, so Proof Colors still apply.
- Previews run with the app's Color Settings: `working` is the chosen working space, not the
  built-in default.
- The `bpc` checkbox (Convert to Profile, Proof Setup) reads "Black Point Compensation" instead of
  "Bpc"; every catalog already translates it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:35:17 -04:00
Carter Smith e9036c72c6 Focus font search when opening the picker (#7222)
* Focus font search when reopening the picker

* Keep font search focused through the opening click release
2026-10-18 03:35:12 -04:00
Jason Xiang 37beb18454 fix(codecs): keep EXR color channels within one layer (#5866) 2026-10-18 03:35:05 -04:00
David MannandClaude Opus 5.5 8a5a680674 Menus: one checkmark column keeps every label aligned (#5705) (#5809)
Menu rows prefixed their label with "✔ ", three spaces or nothing, so
unchecked toggles (Lock Guides, Lock Slices) sat at a different x than
checked and plain rows, most visibly in CJK translations. Every row,
submenu headers included, now reserves one fixed-width checkmark column
(the width of "✔ " in the button font) and draws the mark in it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:34:58 -04:00
Korg1ch 295a28a50b fix(ui): Ctrl/Cmd-drag in the Navigator zooms to the drawn rectangle, as in Photoshop (#6872) (#7140)
* fix(ui): Ctrl/Cmd-drag in the Navigator zooms to the drawn rectangle, as in Photoshop (#6872)

* test(ui): cover the Navigator zoom rectangle (#6872)
2026-10-18 03:34:52 -04:00
David MannandClaude Opus 5.5 44dc7c2c77 OpenRaster: layer locks no longer change silently on save (#7018) (#7019)
* OpenRaster: layer locks no longer change silently on save (#7018)

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

* ORA: the Background's position lock is not reported as lost (#7018)

Every new document's Background carries a position lock, so the warning made a
plain .ora unsavable in place.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:34:47 -04:00
Michael HigginsandClaude Opus 5.5 a914c58a63 fix(ui): wide ellipse for Elliptical Marquee, dashed C + brush for Quick Selection (#6875) (#6917)
The Elliptical Marquee used a dashed circle and Quick Selection a brush
inside a dashed circular arc, so the two read alike. Photoshop draws the
marquee as a horizontal ellipse and Quick Selection as a brush in an
upright dashed rounded C. New original ellipse-dashed.svg; quick-selection.svg
redrawn; tests check the ellipse is wider than tall and the C runs down
the left edge.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:34:42 -04:00
NetterSilverandClaude Haiku 5.5 866e7bfe4e Upload 16-bit layer masks as R16Float instead of R32Float (#6845)
Fixes #6534

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:34:37 -04:00
NetterSilverandClaude Haiku 5.5 dfb4aa70ae Keep Color Sampler points on their pixel after flips and turns (#6720)
Found while testing crates/engine/src/commands.rs:117-120 (turn) with crates/engine/src/canvas_geom.rs:224 (Turn::affine); applied in crates/engine/src/lib.rs:807-814 (Session::edit)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:34:32 -04:00
David MannandClaude Opus 5.5 0296c8e0b5 Remove tool: every queued stroke is tinted in full (#6555) (#6642)
With Remove after each stroke off, every finished stroke was fed into the
one shared tint trail, whose point counter still held the previous
stroke's length, so later strokes were stamped only past that count.
Trail::feed_stroke starts each new stroke's count at zero.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:34:27 -04:00
denstu473 68dfaf1ae6 Check unclamped transform dimensions against the pixel budget (#6493) 2026-10-18 03:34:21 -04:00
David MannandClaude Opus 5.5 e6c31f4d4a Menus: rows scrolled out of view no longer open their submenus (#5896, #6225) (#6356)
A tall menu reopens where it was last scrolled to, so after reaching Edit ›
Preferences › Settings… or Window › Theme its top rows lie hidden above the
view, over the menu bar. egui opens a submenu whenever the pointer is over its
row, clipped or not: the hidden submenu row under the title opened on the
press, and the release on the title counted as a click outside that submenu,
which closed the whole menu. Hovering the top scroll arrow scrolled the menu
back up, which is why that "fixed" it.

Submenu rows are now drawn like `ui.menu_button` but skip egui's submenu
logic while the pointer is over a part of the row that is scrolled out of
view (an already open submenu, e.g. from the keyboard, is drawn as before).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:34:16 -04:00
xsvm f2ae78ed93 perf(psd): 8-bit merged composite unmattes through a table, not a division per sample (#5694) (#6029)
composite_rgba8 removed the white matte from 8-bit RGB composites with
one integer division per colour sample (3 per pixel, ~108 million on a
36 MP file). The result depends only on the sample and its alpha, so the
same expression, now unmatte_u8, fills a 256 x 256 table once per call
and each sample is a lookup. Built per call, not as a static, so no
binary (wasm included) grows by the table.

The new test runs all 65 536 (sample, alpha) pairs through
composite_rgba8 against the replaced loop, kept verbatim as the
reference.
2026-10-18 03:34:11 -04:00
Jason Xiang c41a7957f9 fix(io): exclude transparent palette entries for opaque PNG pixels (#5869) 2026-10-18 03:34:05 -04:00
.CooCoo ea647146db Swatches: the .ase reader refuses files with more than 10,000 groups (#5811)
photocraft_psd::ase::parse counted colors toward MAX_SWATCHES but not
group starts, so the number of groups it read grew with the file's
size. An empty group start is six bytes, and parse held about 64 bytes
for each: in a test build, parsing a 60 MB file of 10,000,000 empty
group starts raised the process's peak memory from 63 MB to 705 MB.
swatches.import reads files of up to 512 MiB, and read_file built a
group for each group start before the import refused more than 10,000
groups.

parse now counts group starts and returns LimitExceeded past
MAX_GROUPS (10,000), the most groups the Swatches panel holds. Files
with at most 10,000 groups parse as before, so every file the import
accepted still loads. Files with more were refused before as well; now
parse refuses them before it holds more than 10,000 groups. write also
refuses more than MAX_GROUPS groups, as it refuses more than
MAX_SWATCHES colors.

ase_group_cap_is_enforced_on_hostile_input parses 10,001 empty group
starts and expects LimitExceeded, then parses the same file with one
block fewer and expects all 10,000 groups. too_many_groups checks that
write refuses 10,001 groups and that 10,000 groups are written and
read back.
2026-10-18 03:34:00 -04:00
David MannandClaude Opus 5.5 0f0f58c2ab PSD: layer_tree_max_depth hoists layers under unclosed dividers past the cap (#5758) (#5759)
Frames past the cap collected nothing, so the layers under dividers that
are never closed were lost once those dividers nested past max_depth,
and nothing was left to hoist at the end. A first pass now matches
dividers to folders and only closed dividers open a frame, so every
frame's depth is the final depth of its nodes and the capped tree equals
layer_tree cut at max_depth, still at most max_depth + 1 levels deep.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:33:55 -04:00
David MannandClaude Opus 5.5 b1de6e8ade Preset Manager: renaming or deleting a brush keeps the current preset and names consistent (#5681) (#5682)
Edit > Presets > Preset Manager renamed and deleted brush presets directly, so a rename
left the current preset pointing at the old name (brush.presets.update then failed), accepted
a case-insensitive duplicate name, and a delete left the current preset dangling. It now uses
the same rules as brush.presets.rename / brush.presets.delete through two small shared
helpers (rename_at, forget_deleted_preset). Patterns and custom shapes are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:33:50 -04:00
068ca57210 Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop (#6660)
* Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop

In Quick Mask mode, painting and Free Transform target the mask, not the
layer. The tab still read "(Layer 1, RGB/8)", so a user who pressed Q by
accident and then rotated saw the red overlay turn with the box and the
layer stay put, with nothing on screen saying why. The tab now reads
"(Quick Mask/8)", the same condition `paint_target` routes on.

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

* Credit arnav-hiwarkar in the About window

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

* Keep contributors/people.toml as on main

The branch removed another contributor's entry; the added entry only repeated the username.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:33:45 -04:00
f6f53f657d Reject notes.set moves that make the popup non-finite (#6174)
Found while testing crates/engine/src/notes_cmds.rs:105

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:33:40 -04:00
Nicolas FalesyandClaude Opus 5.5 2301ecf9e9 fix(linux): file dialog probe sees a portal with no FileChooser backend (#5739) (#6422)
The probe read the FileChooser `version` property and only counted a few D-Bus
error names as "absent". zbus wraps property errors in `Error::FDO`, and a portal
with no FileChooser backend doesn't export the interface at all, so the read fails
with `InvalidArgs` ("No such interface"). Neither matched, the probe said
"inconclusive", and Save As / Export did nothing without the "No file dialog can
open" notice.

Introspect the portal object and look for the interface instead, and treat
InvalidArgs, UnknownMethod and NameHasNoOwner (as `FDO` or `MethodError`) as absent.

Fixes #5739


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:35 -04:00
Michael HigginsandClaude Opus 5.5 0007cad01a Paste: an image pastes with Ctrl+V whichever key is let go first (#6329) (#6360)
With no text on the clipboard (an image copied from a browser, a file from Explorer) egui-winit
swallows the Ctrl+V press and sends only the V release; the paste then ran only when that release
still carried Ctrl. Letting go of Ctrl first, as many people do, pasted nothing, so Ctrl+V seemed
to need several tries. A V release whose press never arrived, after Ctrl was held and before any
other key, is now that swallowed Ctrl+V and pastes once. A plain V tap (the Move tool) and the
web build, which always gets the key-down, are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:29 -04:00
Andrew Barnes 436ca3569c fix(gpu): clamp device limits to adapter support (#5986) (#6050)
Keep backend defaults within supported limits while preserving the
adapter's texture and buffer maxima. Add regressions for V3D shader
variables, weaker GL adapters, minimum alignments and capable devices.
2026-10-18 03:33:24 -04:00
David MannandClaude Opus 5.5 977cf5f6de Krita: uncompressed tiles are read as interleaved pixels (#7082) (#7083)
* Krita: uncompressed tiles are read as interleaved pixels (#7082)

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

* kra: the agent-surface test writes its raw tile interleaved, like Krita (#7082)

The krita.rs fixture wrote its flag-0 tile as byte planes, which only the old
decoder read back correctly.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:19 -04:00
David MannandClaude Opus 5.5 a002e66824 Snapping: layer targets ignore the selection, so a moved selection snaps to guides and the document (#6290) (#6355)
Snap To Layers built each layer's edges and centre from transform_bounds, which clips the
layer to the selection. With a selection, every layer's targets were the selection's own
bounds, so dragging the outline with a marquee snapped it back to where it started (shown
as layer alignments) and beat nearby guides and document edges. Layer targets now use the
layer's whole content bounds, as in Photoshop; the moving box (layer_rect) is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:33:14 -04:00
NetterSilverandClaude Haiku 5.5 275d4cbcf7 Reject out-of-range leading, baseline shift and scales in type styles (#6196)
Found while testing crates/engine/src/type_cmds.rs:287

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:33:09 -04:00
Michael HigginsandClaude Opus 5.5 16774eea6f Twirl, Pinch and Spherize fill the inscribed ellipse (#5643) (#5871)
They acted inside a circle fitted to the bounds' shorter side, so on a wide
or tall image only a central disc changed. Like Photoshop's, they now act
inside the ellipse inscribed in the bounds (selection or layer), reaching all
four edges; square bounds are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:04 -04:00
Michael HigginsandClaude Opus 5.5 c408c83a9f Pencil: a 1 px line paints the same pixels whichever end it starts from, at any Smoothing (#6336) (#6341)
The pixel-grid walker stepped between the pixels holding each segment's ends. Smoothing splits
a Shift-click line into pieces along it (so does the pointer on a Shift-drag), and snapping every
piece to its own pixels bent the line into a jagged staircase; rounding the half-pixel ties away
from zero also made the line depend on which end it started from. The walker now samples the
segment's own line at each column (or row) centre and resolves ties on the pixel grid, so the
pieces of a straight line paint exactly the pixels of the whole line, both ways.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:32:58 -04:00
David MannandClaude Opus 5.5 175bdce659 GPU: a group's effect cache follows its vector mask and children's Blend If (#5706) (#5707)
* GPU: a group's effect cache follows its vector mask and children's Blend If (#5706)

group_key, which decides when a styled group's effect shape and distance
fields are rebuilt on the GPU, hashed the group's children and layer mask
but not the group's own vector mask, nor each child's Blend If and
Advanced Blending. A group's Stroke or Drop Shadow kept the old outline
after those changed. Hash them, as the CPU's layer_identity does.

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

* GPU: pass the pin callback in the group key test after main's signature change

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:32:53 -04:00
FinnNkandClaude Opus 5.5 9ae0f624fc test(type): PostScript names guessed apart from their installed family resolve exactly (#5471)
Covers #5044's two names: BMDoHyeon (family "BM DoHyeon", guessed "BM Do Hyeon") and Daum_SemiBold (guessed "Daum_Semi Bold"). Both resolve to their installed faces and aren't reported missing on main, through the exact PostScript lookup (#1844) that missing-font checks follow since #608.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:07:55 -04:00
MarekSpodBiedry 2e1cc763a7 Stamp Visible fills an empty active pixel layer instead of adding another (#6785) (#6828) 2026-10-18 03:07:50 -04:00
NetterSilverandClaude Haiku 5.5 fb5896f43f Fix generator spaced size keeping size text in asset file name (#6628)
Found while testing crates/engine/src/web_cmds.rs:1171

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:07:45 -04:00
David MannandClaude Opus 5.5 e8ab6846c0 Place Scale Marker: lay out the label in points, not pixels (#5754) (#5755)
fontSize is in points and type.create renders it at resolution/72 px
per point, but the bar height, label height and baseline treated it as
pixels. At 300 ppi a 12 pt label (50 px) overlapped the bar. Lay out in
font_size * dpi / 72 px; type.create still gets points. 72 ppi is
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:07:40 -04:00
David MannandClaude Opus 5.5 5a9fb94b9b Type: Paste Lorem Ipsum inserts into the type layer named by "layer" (#5659) (#5660)
paste_lorem decided whether to insert into existing text from the active
layer, while with_text edits the layer the "layer" param names. With a
pixel layer active, {"layer": T} made a new paragraph layer and left T
unchanged. An explicit "layer" now always goes through with_text, so a
type layer gets the text and any other layer is an error; without
"layer" the behaviour is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:07:34 -04:00
David MannandClaude Opus 5.5 20c3e95a6b EXR: the ACEScg preference applies to every way of opening a file (#6782) (#6783)
The "Open color-less EXR as ACEScg" option was only applied by file.openAs; background
opens (File > Open, drag-drop, Open Recent via Session::start_open) and file.openExrParts
kept io's linear-sRGB default. Both now retag through tag_exr_colorspace, start_open before
the Ask When Opening prompt so it sees the ACEScg profile.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:07:29 -04:00
Korg1ch 84f326fa25 Actions: a Paint Bucket step keeps its explicit colour through .atn (#7199) (#7208)
* fix(atn): a Paint Bucket step keeps its explicit colour through .atn export and import (#7199)

* test(atn): an explicit Paint Bucket colour survives an .atn round trip (#7199)
2026-10-18 03:07:23 -04:00
Korg1ch f04fc80267 fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928) (#6967)
* fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928)

* test(engine): cover flips and turns about the selection (#6928)
2026-10-18 03:07:18 -04:00
NetterSilverandClaude Haiku 5.5 e66941bde1 Ignore a leading UTF-8 BOM in variable data set CSV import (#6485)
Found while testing crates/engine/src/variables_cmds.rs:285-292 (import_data_sets_text; the file is read in import_data_sets at line 279)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:07:13 -04:00
wwew 73cd277a03 fix(engine): check the define-brush tip size multiply before reserving (wasm32) (#6902) 2026-10-18 03:07:08 -04:00
NetterSilverandClaude Haiku 5.5 d722dd8b54 Clamp filter.cameraRaw exposure to its documented -5..5 range (#6876)
Found while testing crates/algo/src/camera_raw.rs:592 (value taken unchecked in crates/engine/src/lens_cmds.rs:137 raw_params)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:07:03 -04:00
David MannandClaude Opus 5.5 8ef5337c29 Measurement Scale: large pixel lengths print their real value (#7016) (#7017)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:06:59 -04:00
David MannandClaude Opus 5.5 13bbf0eefe Custom filter: in CMYK a positive offset brightens (#7076) (#7077)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:06:54 -04:00
David MannandClaude Opus 5.5 f3a9b81b1f Duotone: view and flat export clamp ink density like the Multichannel conversion (#6854) (#6855)
Duotone::render used the raw curve output, so ink curves above 100 % pushed the
simulated print out of gamut (red + blue at full density showed green instead of black).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:06:49 -04:00
David MannandClaude Opus 5.5 7d166edb2a Lens Flare: don't darken 32-bit pixels above 1.0 (#5785) (#5786)
Screen as 1 - (1 - c)(1 - a) pulls a 32-bit value above white down
towards 1.0 (2.0 with a = 0.5 became 1.5, the core became 1.0). Add
a·max(1 - c, 0) instead: the same result for c <= 1, unchanged above.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:06:45 -04:00
David MannandClaude Opus 5.5 363d385114 Trap: refuse locked layers (#5623) (#5624)
Image > Trap wrote the active layer's pixels without checking locks, so a
layer under Lock All or the pixel lock (its own or its group's) was trapped
anyway. Check check_pixels_unlocked before writing, like the fill commands.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:06:40 -04:00
Korg1ch 36d6b669d4 test(engine): Content-Aware Fill with an empty sampling area is refused and changes nothing (#6532) 2026-10-18 09:46:18 +03:00
Korg1ch 0f12ac151f fix(engine): refuse a Content-Aware Fill with nothing to sample instead of filling black (#6532) 2026-10-18 09:19:41 +03:00
skylar taylor-barrick 3794e6fbbe Merge pull request #7118 from nicolasfalesy/perf/stack-mode-moments
perf(stack): one loop per statistic, up to 2.2× faster, bit for bit the same
2026-10-17 23:28:16 -06:00
Nicolas FalesyandClaude Opus 5.5 9ff27ec204 perf(stack): one loop per statistic, 1.3–2.2× faster on one thread
#6462 left Mean, Variance, Standard Deviation, Skewness and Kurtosis 5–15%
slower than the old serial loop on one thread. The cause: `stat` matched on
the mode three times per pixel inside one shared loop. `combine` now picks
the statistic once and hands `combine_with` a closure with a constant mode,
so each statistic gets its own loop with the match folded away. The sample
buffers are sized to the layer count and filled by index instead of `push`,
which re-checked the capacity per sample.

Every output is bit for bit the same (121 hashes match main).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
2026-10-18 06:54:53 +02:00
skylar taylor-barrick 2d0f3b61cf Merge pull request #2376 from Unemars/content-aware-workspace
Content-Aware Fill workspace, as Photoshop's
2026-10-17 22:50:54 -06:00
skylar taylor-barrick 8c30a41e06 Merge pull request #7126 from dotCooCoo/fix/stack-statistics-same-bits-on-windows
Stack Mode: Kurtosis and Skewness give the same bits on Windows and Linux
2026-10-17 21:08:12 -06:00
Michael HigginsandClaude Opus 5.5 4d9f163c61 Linux: Reveal never hands xdg-open a file (#7166) (#7168)
Reveal in Finder on Linux opens the linked file's parent with xdg-open,
which launches whatever it is given. A link stored as `tool.jar/a.png`
names a file as its parent, so Reveal launched that file. Reveal now
refuses a parent that is not a folder.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 20:53:59 -06:00
Michael Higgins 72b4a8c0bf Merge branch 'storytold:main' into fix/5884-delay-load-windows-data-pdf 2026-10-17 22:27:05 -04:00
skylar taylor-barrick ceb85009c7 perf(canvas): a Properties-panel adjustment drag refreshes only the view (#6839) (#7155)
`damage_since` had a case for the Image > Adjustments dialog but none for the
Properties panel's `live_adjust` preview, so every parameter change returned no
damage and recomposited the whole document — `gpu-full` at 24.0 MP.

That is what made #5778 a latency regression at 100 % zoom: it correctly stops
CPU-compositing a 1.5 MP proxy, but the full-size GPU composite replacing it is
per-pixel work and 24 MP of it costs more. The Properties path now gets what the
dialog had: a session-tagged key, the layer's area as damage, and of that only
what the view shows, catching up as the view moves.

Intel UHD P630, 24 MP document, 12 changes, medians:

  v0.9.0    70.3 ms   1.5 MP CPU proxy
  main     203.1 ms   gpu-full, 24.0 MP
  this      47.8 ms   gpu-rect, 1.10 MP

Byte-identical to the full-refresh base on all 12 changes and across a pan.
25 % zoom and PHOTOCRAFT_CPU_COMPOSE are unchanged.

`live_region` does not narrow by the layer's mask: a reveal-all mask keeps its
coverage in the default pixel and reports empty content bounds, which emptied
the damage and froze the preview. Every adjustment layer has one.
2026-10-17 20:21:51 -06:00
Brandon ThomasandClaude Opus 5.5 8407d9ccb1 Release 0.10.0 (#7192)
* Release 0.10.0

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

* Cargo.lock: photocraft-ipp at 0.10.0

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 21:28:00 -04:00
David MannandClaude Opus 5.5 b450baa6eb Image: Trim and merged Duplicate report an error on documents over 2^30 px (#7223)
`compose::flatten` returns an empty buffer for canvases over MAX_RENDER_PIXELS.
Trim (Top Left) indexed `px[0]` of it and Duplicate › Merged Layers Only fed it
to `write_region`'s length assert: both panicked; Trim's other modes reported
"nothing to trim to". Both now return a "too large to flatten" error first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:02:31 +02:00
dotCooCoo 0bdea678e7 Merge main into fix/abr-repeated-brush-names 2026-10-17 18:02:25 -07:00
carterwsmith 61db2b2bba Group all selected layers from the Layers panel footer 2026-10-17 18:00:15 -07:00
UnemarsandClaude Fable 5.1 66c0e4ce13 Content-Aware Fill workspace: review follow-ups (#2376)
Testing on a 6000 × 4000 document found the proxy's sampling state computed
per pixel against every stroke segment: each stroke got slower, and an undo
tested 1.7 M pixels × 3000 segments on the UI thread, blocking it for over a
minute. The proxy is now painted as the engine paints the full-resolution
mask: the option's area and the fill area are sampled once at the proxy's
pixel centres (set_base), and each stroke is painted over them in order,
visiting only its own pixels (paint, restate_all), so a dab costs its segment
and a rebuild costs the strokes' areas.

Also from the same review:
- `edit.contentAwareFill {"ui": …}` with the workspace closed is an error
  instead of a fill with the defaults (an agent polling the state after an
  Esc added a fill per poll).
- The view sends one Down per press: the press is the stroke's first point
  and the frames before egui's drag threshold are its next ones, instead of
  drag_started() beginning the stroke over (the lasso lost its first points
  the same way; the Zoom tool zoomed on every frame the button was held).
- The preview texture is box-averaged to the device's max_texture_side when
  the plan's rectangle exceeds it (a 17000 px wide plan gave a validation
  error every frame).
- A preview that fails counts as shown for that state (Preview::finish), so
  it is reported once instead of restarting on every repaint.
- The view shows the document's visible layers composited (an Apply to a new
  layer shows); the engine's limits on strokes, lasso edits and points hold
  at the brush with a message instead of failing Apply; the footer is
  Photoshop's reset, OK, Apply, Cancel on every platform; a leftover
  `let _ = i;` is gone.

Tests: strokes painted once and equal to the engine's sampling area; `ui`
with the workspace closed; the first points of a stroke and a lasso through
egui_kittest; the preview texture at a 2048 limit; a failing preview
reported once; the view after Apply to a new layer; the limits at the brush.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 02:20:20 +02:00
Higgs32584andClaude Opus 5.5 204c76ce36 fix(psd): an edited type layer keeps its Txt2 object instead of a bare stub (#6968)
When a type layer's text changed, build_txt2 replaced the layer's object in
Photoshop's own Txt2 block with a bare `<< /0 << /0 text /6 runs >> >>`:
no paragraph runs (/5), no view (/1, which names the object's frame) and none
of the style keys. Photoshop 27.10 quits while opening such a file
(layer_effects.psd after type.edit), as it refused stripped objects in #2374.

An edited object now starts from the file's object at its slot: the text is
replaced, the paragraph runs are remade one per paragraph of the new text,
and every other run array of the model (style runs, ...) is cut or extended
to the new length before the auto-kern modes are applied. The view and other
keys are kept; only the old text's glyph pen positions (/21) are dropped.
An object whose run arrays aren't shaped as Photoshop writes them still falls
back to a fresh one.

Test: text edited_text_keeps_the_files_txt2_object.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:03:18 -04:00
David MannandClaude Opus 5.5 e1ce85a5df PSD: Hue/Saturation export writes each triplet in its own slot (#7065)
The hue2 block holds a colorize triplet at byte 4 and a master triplet at
byte 10. The writer copied the active triplet into both, so a Colorize hue of
200 also landed in the master slot (outside its -180..180 range) and toggling
Colorize in Photoshop showed the wrong values.

Write the active triplet at its own offset and Photoshop's defaults in the
other (colorize 30/25/0, master 0/0/0), with the colorize hue stored signed as
Photoshop does (200 -> -160). The reader maps a negative colorize hue back to
the model's 0..360.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:55:28 +02:00
dotCooCoo c2cf0e0610 Merge main into fix/mixer-brush-clean-and-load 2026-10-17 16:28:35 -07:00
skylar taylor-barrick f8cd3e7982 chore: clear the rustc 1.96 clippy lint that fails CI
Unrelated to this branch's change; main already fails clippy --all-targets -- -D warnings
without it, which is what failed the Windows integration job here. One
clippy::nonminimal_bool site in canvas.rs, taking clippy's own rewrite: the
negation stays outside, so the is_finite guards and their NaN behaviour are
unchanged. A sweep of all 30 workspace members found no other site.
2026-10-17 17:08:37 -06:00
dotCooCoo 3c9c93ed8c GPU startup: Auto on Windows leaves Vulkan out when an Intel adapter is present (#4)
With the `auto` backend on Windows, eframe created its wgpu instance with
Vulkan, and creating it, listing its adapters and asking each adapter
about the window's surface loaded and ran the Intel Vulkan driver
(igvk64.dll) before the adapter selector could pick Intel's DX12
adapter. When the driver crashed there, the first launch died, and only
the next one moved to DX12 through the startup marker.

`configure` now lists the DX12 adapters first, with a DX12-only
instance, when the plan is `auto` on Windows without WGPU_BACKEND. If
one of them is an Intel adapter, the instance is created with DX12 and
GL only (GL stays the last resort), so no Vulkan driver is loaded. On a
system with an Intel and a discrete GPU, the discrete GPU then runs on
DX12 instead of Vulkan. WGPU_BACKEND, --safe-gpu, a chosen backend and
the crash fallback are unchanged, and System Info still shows the Intel
DirectX 12 note.

The decision is `windows_auto_backends`, tested with vendor lists.
2026-10-17 15:28:20 -07:00
Michael Higgins 2e3aa8ded2 Merge branch 'main' into fix/5884-delay-load-windows-data-pdf 2026-10-17 18:17:21 -04:00
Higgs32584andClaude Opus 5.5 86f6199b01 Print: load Windows.Data.Pdf.dll before using it; fix the error hint (#5884)
Review follow-up on #6395:
- Check that the DLL actually loads (winsafe `LoadLibrary`, no unsafe) rather than that the
  file exists: a DLL that is present but cannot load still aborts at the delay-load stub.
- The hint no longer says to clear Print Settings; that doesn't take a page off the PDF path.
  It now points to Save as PDF, or printing an RGB image without page text or labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:53:11 -04:00
Derek GliwaandClaude Opus 5.5 2cf274f991 Brushes: accept .abr files up to 1 GiB
Import Brushes refused .abr files over 512 MiB: the engine's preset file cap
and the .abr decoder's sample budget were both 512 MiB. Raise both to 1 GiB
for brush files only; gradient, swatch, action and image-tip imports keep the
512 MiB cap. A compile-time assert keeps the reader and decoder caps equal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:38:11 -05:00
David MannandClaude Opus 5.5 193f495e5a Perspective Crop and Rotate Arbitrary keep the Background opaque (#7138)
Both filled the Background's uncovered area with the background colour
including its alpha (tools.setColors accepts one), leaving see-through
pixels on a Background. They now use alpha 1, as crop_doc does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:44:29 +02:00
David MannandClaude Opus 5.5 2f038c5eb2 Perspective Crop: W and H follow the Point/Pica Size preference (#7135)
size_params converted points at a hard-coded 72 per inch. It now uses
Preferences › Point/Pica Size, as the rulers, the type tools and the
Crop tool do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:37:40 +02:00
Michael Higgins 4146d61b78 Merge branch 'storytold:main' into fix/6884-pen-tool 2026-10-17 13:36:48 -04:00
David MannandClaude Opus 5.5 2a690f0f71 Crop: a refused crop keeps the frame for fixing (#7133)
commit_crop cleared the box Undo, took the frame, turned the view upright
and reset the angle before running image.crop, and restored nothing when
the engine refused it (e.g. a resolution past 10 000 ppi). It now keeps
the frame, its turn, its box Undo/Redo and the view and puts them back on
an error. Leaving the tool with a refused frame cancels it instead of
retrying every frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:30:02 +02:00
David MannandClaude Opus 5.5 22cacbf198 Perspective Crop: switching documents drops the pending box (#7130)
perspective_crop_ui::cancel_stale ran only on a pointer event or a
commit, so after a tab switch the other document's quad was drawn (and
reported) until the next press. sync_views now runs it next to the Crop
tool's cancel_stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:21:38 +02:00
Nicolas FalesyandClaude Opus 5.5 4b660fcffa perf(camera-raw): Shadows/Highlights 1.5× faster, bit for bit the same
The fast local Laplacian filter upsampled every remapped level in full for
every reference (up to 96), but each pixel reads that level for at most two
references. `llf` now samples it only where the weight is nonzero, with
`up_at` (same terms, same order as `up`; row taps found once per row,
`mirror` only near the edges). `quantile` selects its element instead of
sorting the whole work copy (5 sorts per render, serial).

36 MP dialog, all cores: Shadows 252 -> 168 ms, Highlights 275 -> 184 ms,
all 14 sliders 627 -> 411 ms. Output hashes match main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
2026-10-17 13:18:21 -04:00
David MannandClaude Opus 5.5 b052fd591e Crop: Straighten is one box Undo step (#7127)
The Straighten branch of crop_ui::pointer returned before the press step
was recorded and pushed, although it turns the frame and makes it
pending. With a crop pending, Undo then undid the document (or skipped a
box step). It now records the step like every other frame gesture,
through a shared push_press_step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:15:25 +02:00
David MannandClaude Opus 5.5 617b75597c Image Size: type keeps its pixel size when the resolution changes (#7124)
image.imageSize set the new resolution and then resampled, which scales
the type transform by the pixel factor and re-renders at the new ppi
(text px = pt x dpi / 72): doubling 72 -> 144 ppi made text 4x as tall.
With Resample off the pixels stayed but the next re-render doubled them.

canvas_geom::set_resolution now changes the resolution keeping type the
same size in pixels, as Photoshop does: text space grows by new / old, so
the box and path scale with it and the layer transform undoes that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:08:58 +02:00
David MannandClaude Opus 5.5 fd99c1f464 Perspective Crop: a drag without area leaves no crop box (#7061)
A straight drag (no width or no height) made a zero-area quad that could neither be committed nor replaced by a new drag, only cleared with Esc. Such a drag now leaves no box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:45:34 +02:00
skylar taylor-barrick 8c12449819 Save: keep the replaced file's metadata (#6603, #7073, #7094, #7095, #7096)
Saving writes a temporary file and renames it over the target, so everything
attached to the old file went with the file that was unlinked. Several of those
are security decisions, so a save quietly undid them: a download lost its Mark
of the Web, an explicit ACL fell back to whatever the folder hands down.

New crates/format/src/atomic/meta.rs, reached from atomic_write through two
AtomicFs methods (copy_permissions became copy_metadata; replace_existing is new
and defaults to rename, so the failure-injecting test file systems are
unchanged):

- Extended attributes (#6603): flistxattr/fgetxattr/fsetxattr through safe
  rustix, per attribute, best effort. macOS Finder tags, com.apple.quarantine
  (the Mark of the Web's macOS counterpart) and Linux user.* all ride along.
  Attributes the system owns refuse to be written and are skipped;
  com.apple.decmpfs is skipped deliberately, since it describes storage the new
  bytes do not have.
- macOS ACL (#7073): a new macOS-only exacl dependency (MIT).
  fcopyfile(COPYFILE_ACL) is an unsafe fn and fclonefileat(CLONE_ACL) is
  APFS-only and would mean writing every save as clone-then-truncate; the
  reasoning is in docs/decisions.md. A file with no ACL is left alone.
- Windows (#7094, #7096): ReplaceFileW through winsafe's kernel feature (MIT,
  no dependencies, a safe wrapper; the workspace forbids writing the FFI call
  here). One call keeps the destination's DACL, its named streams including
  Zone.Identifier, and its creation time.
- Automation write path (#7095): AuthorizedWorkspace::write copies the replaced
  file's attributes through the two cap-std descriptors, never a path, so a
  request path still cannot escape the write root. Permissions there remain
  #4963; ReplaceFileW is path-based and so is not used on that path.

The order inside copy_metadata matters: attributes and ACL first, permission
bits last. Writing an attribute needs the destination open for writing, so
copying a 0444 mode onto the temporary file first locks you out of your own
temporary file. Covered by a regression test.

Atomicity is not weakened. The metadata step runs on the temporary file before
the swap, never touches bytes, and never fails a save. Windows is the one place
the swap itself changed, and a backup file name is passed because it is a
correctness requirement, not an option: ReplaceFileW without one documents
ERROR_UNABLE_TO_MOVE_REPLACEMENT as leaving the replaced file deleted and the
replacement under its temporary name, which is a failed save that loses the
user's document. With a backup name the same failure leaves both files alone.
ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, the one error that does move things, is
unwound by hand; every other error falls back to the plain rename, i.e. to the
previous behaviour.

THE WINDOWS PATHS ARE UNVERIFIED ON WINDOWS. They were written and reviewed on
a Mac. The #[cfg(windows)] tests -- Zone.Identifier, a second named stream,
icacls output, creation time -- have never executed. They do compile and pass
clippy -D warnings against x86_64-pc-windows-msvc, which is as far as a Mac
gets. CI's Windows job is the first real run, and #7094 in particular (Mark of
the Web surviving a replace) is the whole point of the change and is the part
no one has observed working.

Tested on macOS: a real chmod +a entry and a real Finder tag both survive a
save (checked with ls -le and xattr, and as unit tests), plus a 64 KiB
attribute value, a read-only file, the mode, and the automation path.

Still open: Linux POSIX ACLs are not carried over (system.posix_acl_access is
not reported by listxattr, so it needs its own step; not part of these issues),
and macOS creation time is still reset by a save (that half of #6603 needs
setattrlist, which has no safe wrapper here).
2026-10-17 10:43:09 -06:00
David MannandClaude Opus 5.5 638912f0a6 Merge main into fix/6575-psd-unknown-adjustment
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:36:20 +02:00
David MannandClaude Opus 5.5 4286ff86a9 Merge main into fix/6550-selected-path-outline
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:30:12 +02:00
David MannandClaude Opus 5.5 5d6e0331e3 Merge main into feat/tool-213-4-cam-preview-large
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:30:04 +02:00
David MannandClaude Opus 5.5 b6f18820c6 Merge main into fix/pcraft-load-nonfinite
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:26:31 +02:00
David MannandClaude Opus 5.5 e30cedbb2a Merge main into fix/transform-fill-cache
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:24:17 +02:00
David MannandClaude Opus 5.5 c5b1f7769a Merge main into fix/6539-lightness-track
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:21:07 +02:00
David MannandClaude Opus 5.5 a4190ad6a0 Merge main into fix/droplet-conditional-actions
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:20:49 +02:00
David MannandClaude Opus 5.5 0e98028f24 Merge main into fix/tiff-filter-effects-sections
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:18:51 +02:00
David MannandClaude Opus 5.5 cdf7f2c7fe Merge main into fix/gradient-offset-resize
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:13:04 +02:00
itsF4LCON 3bca0123b0 Type: the blink-phase test finds the caret by its strokes
arrow_keys_reset_caret_blink_phase (#5512) looked for a 1.5-wide line, the old caret. The caret is now CARET_STROKES (a 3 pt white halo and a 1 pt black line), so the test matches those.
2026-10-17 18:12:29 +02:00
David MannandClaude Opus 5.5 847dc9eb08 Merge main into fix/auto-align-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:10:39 +02:00
David MannandClaude Opus 5.5 f0cd444a53 Merge main into fix/6535-clipped-adjust-damage
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:10:24 +02:00
David MannandClaude Opus 5.5 0aa456edc3 Merge main into fix/6267-psd-hue-sat-sf
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:06:09 +02:00
itsF4LCON 6d521c2d3e Merge remote-tracking branch 'upstream/main' into fix/6085-text-caret-contrast 2026-10-17 18:04:21 +02:00
David MannandClaude Opus 5.5 363462e8d2 Merge main into fix/6268-psd-perspective-warp-sf
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:03:47 +02:00
dotCooCoo 074d4b08b6 Stack Mode: Kurtosis and Skewness give the same bits on Windows and Linux
`stat` raised each deviation from the mean to a power with `f32::powi`,
with the exponent passed through the moment closure. On
x86_64-pc-windows-msvc that call goes to the C runtime's `powf`, whose
cubes and fourth powers can differ from repeated multiplication in the
last bit. On Linux the same call gives the bits of repeated squaring, as
compiler-builtins' `__powisf2` computes it. Kurtosis
came out one ULP higher on Windows in two values of the test fixture, and
`stack::tests::statistics_are_bit_identical_to_the_serial_implementation`
failed there.

The moments and Skewness's `sd³` now use `ipow`, which multiplies by
repeated squaring: `x·x²` for the cube and `x²·x²` for the fourth power.
Results on Linux do not change, and the recorded bits stay as they are.
2026-10-17 08:58:44 -07:00
David MannandClaude Opus 5.5 3096e456dd Merge main into fix/preset-group-rename-unique
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:24:08 +02:00
David MannandClaude Opus 5.5 340f282c8a Merge main into fix/extending-crop-keeps-hidden-background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:22:59 +02:00
David MannandClaude Opus 5.5 50df815d99 Merge main into fix/crop-default-mode-box-undo
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:19:55 +02:00
David MannandClaude Opus 5.5 030818dcc2 Merge main into fix/image-size-resolution-physical
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:16:40 +02:00
David MannandClaude Opus 5.5 d4d5e9cf93 Merge main into fix/6988-ui-set-f32-range
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:11:07 +02:00
David MannandClaude Opus 5.5 bdeff4fd6c Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:08:28 +02:00
David MannandClaude Opus 5.5 931e762899 Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:08:26 +02:00
David MannandClaude Opus 5.5 af85bd7b0a Merge main into fix/stamp-down-keeps-clipping
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:08:18 +02:00
David Mann 4616bfb256 Merge origin/main into fix/atn-crop-units 2026-10-17 17:00:34 +02:00
David Mann f5ea68d776 Merge origin/main into fix/merged-background-locks 2026-10-17 17:00:26 +02:00
David Mann 3f45847cb3 Merge origin/main into fix/gradient-style-scale-range 2026-10-17 17:00:15 +02:00
David Mann dc71c6440c Merge origin/main into fix/gif-delay-below-1fps 2026-10-17 17:00:12 +02:00
David Mann 73e48771a7 Merge origin/main into fix/conditional-mode-change-gray 2026-10-17 17:00:10 +02:00
David Mann 378f2eb680 Merge origin/main into fix/lens-smart-filter-focal 2026-10-17 17:00:07 +02:00
David Mann 97059e99fd Merge origin/main into fix/transform-selection-reference 2026-10-17 17:00:05 +02:00
David Mann 671878c014 Merge origin/main into fix/crop-angle-readout 2026-10-17 17:00:02 +02:00
David Mann 68629b4cf8 Merge origin/main into fix/crop-size-point-pref 2026-10-17 16:59:59 +02:00
David Mann 3549195cf4 Merge origin/main into fix/match-zoom-clamp 2026-10-17 16:59:57 +02:00
David Mann 3d1f76a367 Merge origin/main into fix/headless-index-keeps-active-doc 2026-10-17 16:59:54 +02:00
David Mann b18b64c039 Merge origin/main into fix/headless-save-quality 2026-10-17 16:59:51 +02:00
David Mann 05c431317f Merge origin/main into fix/actions-delete-drops-fkey 2026-10-17 16:59:43 +02:00
David Mann 33d6b06693 Merge origin/main into fix/crop-target-size-samplers 2026-10-17 16:59:41 +02:00
David Mann f8c7955699 Merge origin/main into fix/artboards-to-files-marks 2026-10-17 16:59:38 +02:00
David Mann 45ad14fbac Merge origin/main into fix/timeline-setprops-work-area 2026-10-17 16:59:35 +02:00
David Mann 1193ebed08 Merge origin/main into fix/shortcut-set-keeps-action-fkeys 2026-10-17 16:59:33 +02:00
David Mann dd9e44706a Merge origin/main into fix/multichannel-channel-target 2026-10-17 16:59:30 +02:00
David MannandClaude Opus 5.5 a32b588ae6 Crop / Canvas Size: extending keeps the Background's hidden pixels (#7086)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:58:36 +02:00
dotCooCoo 274d7c0a36 Merge main into fix/mixer-brush-clean-and-load 2026-10-17 07:47:04 -07:00
David MannandClaude Opus 5.5 cb5b1f8e9e Crop: in default mode, box Undo/Redo keeps the box in place (#7088)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:45:16 +02:00
ebosido444-ai fcd337e1fc perf(text): rasterize a type layer's pixels on all cores, bit for bit the same
A type render spends most of its time after the glyphs are drawn: composing each
colour's coverage over the premultiplied accumulator, dividing it back to straight
colour, and converting the tile bytes. All three are per pixel - no pixel reads a
slot another wrote - so they ran one after another on one core for no reason.

Measured on a 3.75 MP paragraph (4000 characters, 14 pt at 300 dpi, 1851 glyphs),
release, Windows 11, i5-10300H: rasterize 98.3-103.9 ms before, 60.3-61.8 ms after
(1.6-1.7x). Whole-render stage profile before: ink rect 4.1, draw 18.6-22.9, prefix
sum 7.0-8.1, composite 22.6-33.4, unpremultiply 11.2-14.0, write_region 23.8, prune
3.1 ms.

The glyph drawing, the winding prefix sum and the ink-rect pass stay on one thread:
the first two carry a running accumulator across pixels, and the band split that
would keep them bit-identical costs a second glyph traversal per band.

Native only (rayon, as the other crates): the web build keeps the serial loops.
2026-10-17 17:42:50 +03:00
David MannandClaude Opus 5.5 4fc492d2ca Actions: an imported Crop step in a unit other than pixels is not applied as pixels (#7084)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:40:40 +02:00
David MannandClaude Opus 5.5 8574f8d809 Merge to HDR Pro and merged duplicates: the Background layer is a real Background (#7078)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:39:44 +02:00
David MannandClaude Opus 5.5 0b1953c494 Crop: W x H x Resolution follows the Point/Pica Size preference (#7038)
The crop tool's W x H x Resolution mode converted points and picas at a
fixed 72 pt/in, while Image Size, Canvas Size (#5757), the rulers, Info
and the shape dialog use Preferences > Units & Rulers > Point/Pica Size.
With Traditional (72.27), W "72 pt" x H "1 in" at 300 ppi cropped to
300 x 300 instead of 299 x 300.

target() and ratio() now take the points per inch, and the callers
(commit_params, preset_ratio, fit_frame) pass the preference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:45:51 +02:00
David MannandClaude Opus 5.5 b2f9c45c6a Match Zoom: matched views stay within each document's zoom range (#7036)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:45:34 +02:00
David MannandClaude Opus 5.5 758ad4db44 Crop: the turn readout never shows -0.0° or -180.0° (#7040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:45:13 +02:00
David MannandClaude Opus 5.5 cb185320ed Conditional Mode Change: "gray" as a single source mode matches Grayscale (#7042)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:44:56 +02:00
David MannandClaude Opus 5.5 7ebaa94a82 Render Video: GIF frame delay follows timelines under 1 fps (#7045)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:44:43 +02:00
David MannandClaude Opus 5.5 01a58e8f6f Layer Style: gradient overlay and stroke scale stay within 10-150 % (#7047)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:44:42 +02:00
David MannandClaude Opus 5.5 1bb3542b99 Lens Correction: smart filters keep the exact focal length (#7029)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:42:14 +02:00
David MannandClaude Opus 5.5 276d830e7c Transform Selection: an unknown reference point is an error (#7027)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:40:09 +02:00
David MannandClaude Opus 5.5 e1c119200b Automation: place and layer/data-set export by index keep the active document (#7025)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:37:18 +02:00
David MannandClaude Opus 5.5 c6ea59461f Automation: doc.save rejects a bad quality instead of ignoring or clamping it (#7023)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:36:02 +02:00
David MannandClaude Opus 5.5 000fec6f59 Keyboard Shortcuts: switching sets keeps action function keys (#7004)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:43 +02:00
David MannandClaude Opus 5.5 30505e3b53 Crop: Color Samplers scale with a W x H crop (#7012)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:30 +02:00
David MannandClaude Opus 5.5 fd2c10bba8 Artboards to Files: paths, notes, slices and marks move with the board (#7010)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:17 +02:00
David MannandClaude Opus 5.5 361a1bf22e Channels: Multichannel conversion keeps the targeted channel (#7002)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:14 +02:00
David MannandClaude Opus 5.5 99c6fb4ce7 Timeline: a longer duration moves a work area that reached the old end (#7006)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:10:46 +02:00
David MannandClaude Opus 5.5 1c312c4ba6 Actions: deleting an action removes its function key (#7014)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:10:33 +02:00
David MannandClaude Opus 5.5 82707fd15d Stamp Down keeps the lower layer's clipping, label and link (#6999)
Stamp Down rebuilt the layer below from scratch and copied only its id,
name, blend mode, visibility and locks, so a clipped layer came back
unclipped (its pixels showing outside the clip base) and lost its colour
label and link. Photoshop writes the stamp into the existing layer; keep
the three properties as Merge Down already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:04:16 +02:00
David MannandClaude Opus 5.5 8b61a8b186 Control: ui.set rejects zoom, rotation, center and dockWidth beyond f32 range (#6988)
A finite f64 like 1e39 became inf on `as f32`: zoom jumped to 100%,
rotation reset to 0, dockWidth fell to its minimum and center was stored
as [inf, 0], all with ok:true. A new f32_field (same filter as
rotate_view's finite_number) rejects these in the validate phase, so a
rejected call changes nothing. num_field stays as is: pointer events
must keep accepting huge coordinates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:08:03 +02:00
David MannandClaude Opus 5.5 e4c41e147e Image Size: Resolution with Resample on keeps the print size in physical units (#6979)
With Resample on and the unit in inches/cm/mm/pt/picas, a new Resolution
only stored the resolution; width/height are kept in pixels, so the print
size shrank (720 x 576 px at 72 ppi, 10 x 8 in, set to 300 ppi stayed
720 x 576 px = 2.4 x 1.92 in). Photoshop keeps the print size and
resamples: 3000 x 2400 px. Scale the stored pixels by new/old resolution
in physical units; pixels and percent keep the pixel count as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:38:50 +02:00
NetterSilver 158dcc6731 Fix engine compile error: load export presets from presets JSON
Fixes #6911

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit bb7a5f2309)
2026-10-17 13:34:00 +03:00
PerchAnton 120cfe19a2 Merge origin/main into fix/finite-command-parameters
Resolve Color to Alpha validation conflict while preserving the upstream array guards and finite color parsing.
2026-10-17 13:34:00 +03:00
Finn NewickandClaude Opus 5.5 dd57638d9e fix(psd): make packbits::decode_into crate-private and overflow-safe
decode_into computed out.len() + expected unchecked. With a non-empty
buffer and an expected length near usize::MAX it panicked in debug builds
and, in release, wrapped and returned Ok having decoded nothing. Compute
the target with checked_add and report LimitExceeded, and test each packet
as len > target - out.len() so the bound checks cannot overflow either.

The only callers are decode and decode_rle in this module, and the crate
README documents packbits::{encode, decode} as the public API, so
decode_into becomes pub(crate).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:11:57 +01:00
itsF4LCON 85aa10b16d Merge remote-tracking branch 'upstream/main' into fix/6085-text-caret-contrast 2026-10-17 11:01:10 +02:00
dotCooCoo 8f971cc79f Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off
The Mixer Brush could not be emptied. MixerState::clean dropped only
the pickup well, paint.mixerBrush refilled the reservoir from the
foreground before every stroke unless loadAfterStroke was false, and
the app always sent the defaults. A stroke on a new layer over an image
always carried the foreground color, and the options bar had no Load
Brush or Clean Brush control.

Cleaning now empties both wells, so a clean brush lays down only the
color it picks up from the canvas. The new commands brush.mixer.load
and brush.mixer.clean load and empty the brush. paint.mixerBrush reads
loadAfterStroke and cleanAfterStroke from the brush and applies them
after the stroke. Both are on by default, and a stroke with both on
paints as before. A stroke whose color differs from the one recorded
by the last stroke, load or clean loads that color first. A load or
clean records the foreground, so choosing a new foreground loads the
reservoir.

The options bar has the Current Brush Load swatch, whose menu holds
Load Brush and Clean Brush, and two toggles for the automatic Load and
Clean. Brush Settings shows the same two options. The new strings have
rows in every catalog.
2026-10-16 22:49:47 -07:00
Higgs32584andClaude Opus 5.5 425e782ac2 Pen: a finished path keeps its anchor squares on screen (#6884)
Closing a path or pressing Enter with the Pen left only the 1 px outline, so the
path looked gone and the anchors to edit were hidden. The Pen, Freeform Pen and
Curvature Pen now draw the active paths' anchors as Path Selection does, as in
Photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:25:15 -04:00
jonathnwxh e31905358d Restore export preset field omitted by upstream preset loader 2026-10-17 09:32:44 +08:00
jonathnwxh 1dd50ba859 Merge current upstream while preserving font metadata repair 2026-10-17 09:24:30 +08:00
Thang Nguyen aaf86e2a9c fix(psd): read the split ("quilt") warp of placed layers
Photoshop writes a split custom warp as `quiltWarp` in the placed layer's
SoLd/SoLE descriptor: a grid of Bezier patches (deformNumCols x
deformNumRows control points, patch boundaries in quiltSliceX/Y). Only a
single 4x4 `warp` mesh was read, so such smart objects rendered unwarped
and in the wrong place. Read the quilt grid into a multi-patch
BezierMesh; anything inconsistent is still refused.

On two real mockups (a sneaker and a V-neck jersey), the mean difference
from another PSD renderer over the design area drops from 14.4 to
0.66/255 and from 13.0 to 0.64/255. Refs #4398.
2026-10-17 08:09:29 +07:00
Higgs32584andClaude Opus 5.5 c9334c4661 Windows: a maximized custom-title-bar window lets an auto-hidden taskbar show (#4845)
An auto-hidden taskbar reserves no work area, so the maximize guard left the
caption-less window covering the whole monitor and the shell treated it as a
full-screen app, never revealing the taskbar. On each monitor edge with an
auto-hidden taskbar (found via Shell_TrayWnd / Shell_SecondaryTrayWnd through
safe winsafe calls) the window now stops 1 px short of the monitor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:39:39 -04:00
David MannandClaude Opus 5.5 e1db181435 Guides: ruler guide snapping rebuilds its targets when snap options change (#6858)
The cached guide targets were keyed only by document and revision, so showing the
grid or turning Snap To Layers/Document Bounds off kept the stale targets; the key
now includes the Snap To options.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 01:35:33 +02:00
David MannandClaude Opus 5.5 22217e6ab2 CLI batch: skip shell-only Save and view steps inside played action sets (#6860)
Batch strips Save/Save As/zoom steps from every action of a loaded set, called actions
included, and remaps their unchecked/dialog step indices.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 01:32:48 +02:00
David MannandClaude Opus 5.5 921637322c Native format: refuse a .pcraft with out-of-range numbers when it is opened (#6862)
A number past f32's range (e.g. opacity 1e39) decoded as infinity, so the document opened but
could never be saved again; read_manifest now refuses it as corrupt, naming the field.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 01:30:16 +02:00
jonathnwxh b0484eb72c Remove extra blank line at merged test boundary 2026-10-17 07:24:29 +08:00
jonathnwxh 4d32089b4f Merge upstream main and retain both font metadata and paragraph-wrap regressions 2026-10-17 07:23:25 +08:00
David Mann 9d38b28da6 Merge remote-tracking branch 'origin/main' into feat/file-216-2-large-fixtures
# Conflicts:
#	docs/scorecard.md
2026-10-17 01:19:35 +02:00
David Mann 0739ff48c5 Merge remote-tracking branch 'origin/main' into fix/4017-huge-new-document
# Conflicts:
#	crates/ui-egui/src/gpu_canvas.rs
#	docs/scorecard.md
#	perf/budgets.toml
2026-10-17 01:07:37 +02:00
David MannandClaude Opus 5.5 c354a25a6b Type: paragraph box handles are picked at the unsnapped press (#6819)
With View > Snap on, the canvas snapped the Type tool's press (a point
gesture) before the tool's handle hit test, with the edited text's own
glyph bounds and centre lines among the targets. The snapped press could
leave the 6 pt handle grab area, so the press placed the caret or
committed the edit instead of resizing the box.

- The Type tools pick the box handle or glyph at the raw press, as Free
  Transform already does.
- While editing, only a press on a box handle starts snapping, and the
  resize drag snaps to guides and other layers, not to the text being
  resized; caret drags are no longer snapped.

Canvas-level test drags all eight handles (at the handle and inside its
grab area) at 25-200 % zoom, 1x and 2x HiDPI, plain/scaled/rotated layer.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:19:26 +02:00
MarekSpodBiedry 156b27d65b Object Selection: enclosed background gaps stay unselected, only object-like holes fill (#6556) 2026-10-17 00:06:54 +02:00
David MannandClaude Opus 5.5 89339396ff Droplets: carry the actions that Conditional steps call (#6804)
called_actions only followed actions.play steps, so a droplet whose action
used actions.conditional saved neither branch and failed with "no action
named ..." when run standalone. It now also queues the then/else targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:42:36 +02:00
David MannandClaude Opus 5.5 1338d78b45 Transform: drop a fill layer's cached render when its frame changes (#6808)
Free Transform and Warp moved a fill layer's linked masks (and so the frame its
gradient is laid out in) but kept the cached render, which the compositor draws
as-is while the Fill is unchanged. Drop the cache in apply_transform and
warp_layer, as transform_layer_geometry and Perspective Crop already do.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:42:34 +02:00
Higgs32584andClaude Opus 5.5 cd75fa78ef PSD/PSB: a 16/32-bit file's "Transparency" channel stays the merged transparency on re-save (#4654)
Photoshop's 16-bit PSBs can mark the merged transparency only by naming it
"Transparency" in the alpha channel names (positive Lr16 layer count, no Mt16).
The importer read it as a user alpha channel, and the next save wrote a second
one. merged_has_alpha now also accepts that name when the layers sit in a
global Lr16/Lr32 block and the names cover every extra channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:38:42 -04:00
David MannandClaude Opus 5.5 3afb0b1283 PSD: layered TIFF byte-order conversion swaps every smart filter section (#6802)
Tx::filter_effects converted only the first u64-length section of FEid/FXid and copied the rest as stored, so a second smart object's section kept the source byte order and FilterEffects::parse lost the whole block.
It now mirrors FilterEffects::parse: one section per smart object, each with its 4-byte padding, until only trailing zero padding remains.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:33:20 +02:00
nrq6993-a11yandCursor 2e8d20ca9c Fix rustfmt on snap_ui smart-target assignment.
CI Format step failed on a line break rustc-fmt does not want.

Co-authored-by: Cursor <[email protected]>
2026-10-17 00:24:53 +03:00
David MannandClaude Opus 5.5 8c6412bbb2 Plug-ins: running a filter plug-in respects layer locks (#6770)
plugin.run wrote into raster and smart object layers without checking
locks. It now refuses layers with locked pixels or Lock All like the
built-in filters, and with Lock Transparency it keeps the layer's alpha
(as built-in filters do, via filters::keep_alpha).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:20:35 +02:00
David MannandClaude Opus 5.5 ec83a1117f Presets: refuse renaming a group to a name another group has (#6768)
renameGroup in the shared preset group editor renamed without checking
that the new name was free, so two groups could share a name and a later
replace import of that name removed both. It now refuses like the
Swatches sibling; renaming a group to its own name stays fine.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:20:35 +02:00
David MannandClaude Opus 5.5 0fc5660f60 Image Size: a gradient fill's centre offset stays a fraction of the frame (#6778)
map_fill multiplied the offset by the full linear part of the canvas transform, so Image Size
(and Perspective Crop) scaled it: 200% moved a 25% offset to 50%. Each axis of the linear part
is now normalised, so scale drops out while flips and 90° turns still mirror and turn it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:19:40 +02:00
David MannandClaude Opus 5.5 031ab7d5b2 Apply Image: write into the targeted layer mask (#6780)
With the layer mask targeted, apply_image took the layer branch and fetched the
layer's pixel surface with the target dropped, so the mask stayed unchanged and the
pixels were overwritten. A mask target now goes through the single-plane path and
is written back over the canvas, keeping the mask's default outside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:18:53 +02:00
Ian Swope 9b6a85824d Mesh warp: tiles encoded in parallel and in-bounds taps read directly, about twice as fast with identical pixels 2026-10-16 16:13:44 -05:00
David MannandClaude Opus 5.5 9ac2c08875 Actions: renaming an action updates the Conditional steps that call it (#6772)
actions.rename only rewrote the "action" param of actions.play steps, so an
actions.conditional step whose "then"/"else" named the renamed action failed
with "no action named ..." on the next play. The rename loop now also rewrites
matching then/else branches, in saved actions and the pending recording.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:02:33 +02:00
David MannandClaude Opus 5.5 af5e77b150 Auto-Align: warp layer masks as masks, keeping what lies outside revealed (#6776)
Auto-Align warped a moving layer's mask with the colour warp (warp_placed), which
returns a grey + alpha surface defaulting to 0, so a reveal-all mask hid everything
outside its painted area. Masks now go through warp_gray (planar placements) or
warp_mesh_gray (cylindrical/spherical), keeping one channel and the mask's default.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:01:14 +02:00
David MannandClaude Opus 5.5 bdf44ab18c PSD: read colours stored as HSB (#6774)
color_from_desc had no HSBC arm, so HSB descriptor colours fell through to the
RGB fallback and came back None: solid fills vanished, strokes and effects went
black. Convert HSBC (hue in degrees, saturation/brightness 0..100) with the
existing channel_map::hsb_to_rgb, now pub(crate).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:51:32 +02:00
David MannandClaude Opus 5.5 ab81052308 Fit Image: a width or height of 0 leaves that side unconstrained (#6766)
fit_image read width/height with f64_param and clamped to 1, so a 0 or
negative side (dialogs send 0 for an empty field) became a 1 px box and
shrank the document to 1x1. It now uses size_param like the rest of
file_cmds.rs, so such a side falls back to the document's own size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:49:38 +02:00
David MannandClaude Opus 5.5 01dbccee18 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:42:01 +02:00
David MannandClaude Opus 5.5 f5d9969803 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:37:11 +02:00
David MannandClaude Opus 5.5 b01f6d33b3 PSD: layered TIFF byte-order conversion reads the class of Idnt/indx references (#6764)
The byte-order converter read Idnt/indx reference items as a bare u32, but
they are class + u32 (as descriptor.rs reads them since #4434), so a block
holding such a reference desynced and was dropped (or left unconverted).
Read the class first; regression test round-trips such an lfx2 block.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:33:54 +02:00
David MannandClaude Opus 5.5 43ade8be26 Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:32:33 +02:00
David MannandClaude Opus 5.5 8af5f2b880 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:28:43 +02:00
David MannandClaude Opus 5.5 e5f1ecf501 Merge main into fix/smart-filter-last-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:24:50 +02:00
nanopink 4354113e73 fix: match Photoshop Gradient Fill scale limits 2026-10-16 16:23:53 -04:00
David MannandClaude Opus 5.5 221af61dea Merge main into fix/count-delete-group-active
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:22:55 +02:00
David MannandClaude Opus 5.5 48ed913526 Merge main into fix/video-fps-validate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:19:12 +02:00
David MannandClaude Opus 5.5 824cbdb022 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:19:08 +02:00
David MannandClaude Opus 5.5 dd6f66e3ae Merge main into fix/lens-filters-smart-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:17:26 +02:00
David MannandClaude Opus 5.5 0ea2684679 Merge main into fix/adjust-smart-object-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:15:01 +02:00
David MannandClaude Opus 5.5 73b0bef8a5 Merge main into fix/fill-layer-angle-finite
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:15:00 +02:00
David MannandClaude Opus 5.5 c7f5dfdbc5 Merge main into feat/file-215-18-export-resample
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:13:25 +02:00
David MannandClaude Opus 5.5 699a16e024 Merge main into fix/lock-transparency-atop
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:12:47 +02:00
David MannandClaude Opus 5.5 31df8a357b Merge main into fix/mask-from-transparency-bg-depth
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:12:45 +02:00
David MannandClaude Opus 5.5 b9d646baad Merge main into fix/multichannel-alpha
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:11:01 +02:00
David MannandClaude Opus 5.5 1bd9c7a3dc Merge main into fix/1954-overlay-opacity-stops
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:08:36 +02:00
David MannandClaude Opus 5.5 1b0e69d2f6 Merge main into feat/tool-214-12-shape-feather
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:07:55 +02:00
David MannandClaude Opus 5.5 c1f0a7522a Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:06:11 +02:00
David MannandClaude Opus 5.5 83afd80a87 Merge main into feat/ui-217-4-save-in-background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:01:22 +02:00
David MannandClaude Opus 5.5 1336022624 Merge main into perf/p25-blur-cancel
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:58:23 +02:00
David MannandClaude Opus 5.5 78ae69a456 Merge main into feat/file-215-2-noparam
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:55:46 +02:00
David MannandClaude Opus 5.5 7977bed5c4 Merge main into feat/ui-217-4-animated-zoom
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:55:27 +02:00
David MannandClaude Opus 5.5 362f7168ee Merge main into feat/file-215-2-blur
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:53:52 +02:00
David MannandClaude Opus 5.5 e361737258 Merge main into feat/file-215-2-stylize-pixelate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:53:31 +02:00
David MannandClaude Opus 5.5 5990376d1c Merge main into feat/file-215-2-other
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:50:49 +02:00
David MannandClaude Opus 5.5 1af75eef72 Merge main into feat/file-215-2-sharpen-noise
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:50:26 +02:00
David MannandClaude Opus 5.5 6199c1e04d Merge main into feat/file-215-2-wind-extrude-tiles
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:50:14 +02:00
David MannandClaude Opus 5.5 b9abd1c0d9 Merge main into feat/ui-217-1-tool-options-persist
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:48:33 +02:00
David MannandClaude Opus 5.5 c751ef0ce8 Merge main into feat/file-215-2-mezzotint-halftone
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:47:48 +02:00
David MannandClaude Opus 5.5 2fef710e49 Merge main into feat/4946-ctrl-hover-distance
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:25:44 +02:00
David MannandClaude Opus 5.5 dc71a11d41 Merge main into fix/4809-dual-brush-size
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:25:44 +02:00
vangel76andClaude Opus 5.5 489feb2333 Tests: pcraft keeps text run baseline; tighten sub/superscript size check (#5645)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:57:15 +02:00
David MannandClaude Opus 5.5 5d3eb7f130 Paths: drag a path row onto the Trash to delete it (#6553)
In Photoshop a path is deleted by dragging its row in the Paths panel
onto the Delete (Trash) button at the panel's bottom: the row follows
the cursor and releasing over the Trash deletes that path, whichever
row is selected. PhotoCraft's rows only reacted to clicks, so the
drag gave no feedback and a drop on the Trash did nothing.

Saved path, work path and vector mask rows now start a drag. While it
runs the row's name follows the cursor (the ghost the Layers and
History panels use) and the Trash is outlined when under the pointer;
releasing there runs the row's own delete command (path.delete for a
saved or the work path, layer.vectorMask.delete for a vector mask),
reusing the History panel's dropped_on helper. A drag that never sees
its release is dropped next frame.

A shape layer's path row does not start a drag: what deleting it does
is #6552's change, kept separate.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:56:28 +02:00
David MannandClaude Opus 5.5 ed4e94a479 PSD: open an adjustment layer of an unknown type as an unsupported adjustment (#6575)
An adjustment layer whose block no mapper knows (Photoshop 4's `hue `, or a
type newer than PhotoCraft) opened as an empty pixel layer with no notice.
Its block did survive in psd_blocks, but the layer showed as a paintable
empty pixel layer and saved without the "pixel data irrelevant" flag.

Photoshop writes every adjustment layer without pixels and with "pixel data
irrelevant" set (every adjustment layer in the corpus; pixel, type and smart
layers never have it). A record like that which is not a group, matches no
known content kind, and carries exactly one block that is neither a known
layer-information key nor a known adjustment now imports as
Adjustment::Unsupported with that block, plus the usual "could not be read"
warning naming the key. With two or more candidates nothing is guessed and
the layer stays a pixel layer as before. The rule only fires for layers
without pixels, so no pixels can be lost, and the block is written back
verbatim either way.

Export keeps an unknown-key Unsupported block in place while it is
unchanged; before, it was written a second time beside the preserved copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:52:26 +02:00
David MannandClaude Opus 5.5 e88268d7dd Paths: Delete Path on a shape layer's path row deletes the layer (#6552)
The right-click menu of a shape layer's "<Layer> Shape Path" row in the
Paths panel had no Delete Path, while Photoshop offers it enabled.

Measured in Photoshop: with a rectangle shape layer's shape path targeted
in the Paths panel, Delete Path removes the whole shape layer as one
"Delete Layer" history step (another shape layer below is untouched);
it does not leave a full-canvas fill behind.

The row now offers Delete Path, dispatching the existing layer.delete
command for that row's layer (passed explicitly, so a multi-layer
selection is not deleted with it). Saved and work path rows keep
path.delete and a vector mask row keeps Delete Vector Mask.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:42:04 +02:00
David MannandClaude Opus 5.5 65dc117b4b Shortcuts: a command shortcut leaves the focused menu bar and runs (#6573)
After an Alt tap or F10 the in-window menu bar owns the keyboard, and
shortcuts::handle returned before dispatching anything, so Ctrl+D, F7
and the other command shortcuts did nothing until Esc left the bar.
Since the shortcuts run before the menu bar in a frame, the bar can't
let go of the key itself in time.

menu_keys::leave_for_shortcut takes the keyboard off the bar when a
Cmd/Ctrl combo or a function key other than F10 is pressed while the
bar is focused with no menu open; shortcuts::handle calls it before its
early return, so the shortcut runs in the same press. Arrows, Enter,
Esc, letters, Alt+letter and F10 keep driving the bar, and an open menu
still swallows shortcuts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:03:03 +02:00
David MannandClaude Opus 5.5 86ee9c9c7c Paths: the path selected in the Paths panel is drawn on the canvas (#6550)
Selecting a saved path in the Paths panel drew nothing: the canvas overlay
only drew the work path, the active shape layer's path and a targeted vector
mask. The selected saved path is Photoshop's target path, so it is now drawn
with any tool (anchor squares with Path Selection, and with Direct Selection's
overlay), still hidden by View > Show > Target Path and Extras.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:03:01 +02:00
Davos d21683cae3 fix(ui): disable Batch without a recorded action 2026-10-16 20:01:28 +03:00
AlastorIRL 2406e762b9 Merge remote-tracking branch 'origin/main' into feat/animated-gif-open-and-timeline-fixes
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	crates/ui-egui/src/view_cmds/tests.rs
2026-10-16 13:45:19 -03:00
David MannandClaude Opus 5.5 2f21722eb4 Content-Aware Move: live preview for large selections (TOOL-213-4, #6680)
The live preview only covered selections up to 256x256 px; larger ones (the
command takes up to 16 M) errored, the UI remembered the failure and showed
nothing until release.

Engine: a preview whose selection (or landed area) is over 256x256 is now
solved on a scaled-down copy of the area around the selection and where it
lands (area-average resize of the target, or of the composite with Sample All
Layers, and of the selection), on a one-layer document of its own, then
enlarged back (bilinear, float samples, so any depth) over the selection's
shape where it lands and, for a move, where it left. The landed content is
sampled through the scaled placement, so rounding the scaled offset does not
shift it. ContentAwarePreview::refine gives the full-resolution job, with the
command's own margin, so the refined preview is the command's result bit for
bit. Every step checks the JobCtx; the session is never touched; bad params
and selections over the command's limit still error. Selections within
256x256 take the unchanged path (same output as before, checked by hash
against main). The bilinear resampler used by Transform On Drop now runs its
rows in parallel (same results).

Measured (release, 24 MP 8-bit document, Ryzen 9 7950X with other builds
running): coarse preview 600x400 0.26 s, 1500x1000 0.38 s, 3000x2000 0.69 s
(256x256 full-resolution preview: 0.21 s); the exact refine takes as long as
the command (0.8 s, 4.1 s, 9.4 s).

UI: cam_preview shows the coarse result, then starts the refine as a second
cancellable job and swaps it in; a failed refine leaves the coarse result
shown, and a failed first job is still not retried.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:32:28 +02:00
David MannandClaude Opus 5.5 145acbda8b Layers: toggling a clipped adjustment damages only its base (#6535)
`photocraft_compose::change_bounds` returned "anywhere" for every
adjustment layer before looking at `clipped`, although a clipped
adjustment only changes its clip base's pixels. Hiding or showing one
(or undoing that) therefore recomposited the whole canvas, which on a
heavy document fails the GPU memory fit check and falls back to a full
CPU redraw taking seconds to minutes.

`change_bounds` sees one layer and cannot find the base, so a new
`change_bounds_in(layers, k, canvas)` looks at the sibling list: a
clipped adjustment's change bounds are its base's change bounds. It
stays `None` (anywhere) when no unclipped base lies beneath it (the
compositor then draws it as an ordinary adjustment), when the base is
an adjustment (layers clipped to it composite atop the adjusted
backdrop), and, through `change_bounds`, when the base is a
pass-through group holding an adjustment (visible clipped layers make
that group isolated, so they decide whether its adjustment reaches the
layers beneath) or draws outside its bounds. The engine's
`layers_damage`, `step_damage` and `one_sided_bounds` use it.

Tests: a compose unit test that the clipped adjustment's bounds equal
the base's, that every pixel the toggle changes lies inside them, and
the cases that stay unbounded; an engine test that hide, undo, redo,
show and `layer.setProps` visible report the base's rect (top level and
inside a group), and that recompositing only that rect over the
previous canvas equals a full recomposite.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:04:09 +02:00
David MannandClaude Opus 5.5 ac8d0af24c Hue/Saturation: the Lightness track runs black, grey, white (#6539)
The Lightness slider's track in the Hue/Saturation editor (Properties
panel and dialog, which share it) ran black -> the current hue -> white.
Photoshop's Lightness track is a fixed black -> mid grey -> white ramp
whatever the Hue. lightness_track() now returns that ramp and no longer
takes the hue; the Saturation track still follows the hue. The UI-217-16
scorecard row describes the new track.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:00:11 +02:00
UnemarsandClaude Opus 5.5 87fbb2018f Merge origin/main into content-aware-workspace
Conflicts:
- distort_ui.rs: main's `drop_foreign` (#5092, #5123) closes the Content-Aware Fill workspace
  too when another document becomes active or its own is closed, stopping its background
  preview as Cancel does; what Apply wrote stays. New test
  `the_workspace_is_dropped_when_another_document_becomes_active`.
- sr, tr, vi catalogs: both sides' rows. Dropped es's High, Low, Duplicate Layer and Contract,
  which main has now with the same translations.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:19:21 +02:00
David MannandClaude Opus 5.5 1e3971413f Docs: FILE-215-2 lists Hue/Saturation smart filters as mapped (#6267)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:12:19 +02:00
David MannandClaude Opus 5.5 c467592211 Docs: FILE-215-2 lists Perspective Warp smart filters as mapped (#6268)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:12:19 +02:00
David Mann 50aceedbd4 Merge commit '2a952f164c0b4cf936b1c6497db10dd095341c00' into fix/6267-psd-hue-sat-sf 2026-10-16 13:04:51 +02:00
David Mann a32e7cc328 Merge commit '2a952f164c0b4cf936b1c6497db10dd095341c00' into fix/6268-psd-perspective-warp-sf 2026-10-16 13:04:51 +02:00
itsF4LCON 1302e08791 Arrange: the shown tiles stay put when a visible document is activated (#6017)
With more documents than tiles, the run of shown documents was worked out from the active
index alone, so after activating document 2 in Two-up, a click into the visible document 1
moved the tiles back to [0, 1]. The run is now kept from frame to frame and slides only when
a hidden document becomes active.
2026-10-16 12:04:34 +02:00
itsF4LCON 5b5fc9a80e Merge remote-tracking branch 'upstream/main' into fix/6017-arranged-tiles 2026-10-16 12:02:40 +02:00
itsF4LCON a6211253f8 Merge remote-tracking branch 'upstream/main' into fix/6085-text-caret-contrast 2026-10-16 11:37:08 +02:00
itsF4LCON 73e6671893 Merge remote-tracking branch 'upstream/main' into feat/6278-replace-color-result 2026-10-16 11:37:07 +02:00
highperfocused 4d6f3f4d1e style: cargo fmt (review on #6499) 2026-10-16 11:31:52 +02:00
jonathanwxh-cellandClaude Opus 5.5 9c914a9fd2 Refuse Affinity spot colours nested more than 4 levels deep
paint::color followed a spot colour's RGB equivalent and base colour with
no depth limit. Colours are shared objects, so a spot colour that links to
itself overflowed the stack and aborted the import, and a chain whose levels
link to the next through both took exponential time. Stop after 4 levels of
spot colours defined through spot colours, with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:58:41 +08:00
Marco Arnulfo f59b75b058 Character and Paragraph panels: size, leading, baseline shift and indents follow the Type units preference (fixes #6491) 2026-10-16 09:35:06 +02:00
PerchAnton 3f216a0cd1 fix(engine): reject numeric overflow before document edits 2026-10-16 10:28:57 +03:00
highperfocused a08992c9d9 Desktop: a control/automation flag without its value is a usage error (#6429) 2026-10-16 09:14:24 +02:00
jonathnwxh 670ca40804 Merge upstream and retain both save-type and clipboard regressions 2026-10-16 13:58:18 +08:00
Gert Pellin d8a23d3ec3 Automation: a control token authenticates in either letter case (#6425)
validate_token accepts uppercase hex and server_token keeps the token
lowercased, but authentication_reply passed the client's token to
token_matches unchanged, which compared bytes exactly. A client sending
the token exactly as configured (--control-token or a token file in
uppercase) was refused; only the bundled bridge, which lowercases, got in.

token_matches now folds ASCII letters on both sides, still without an
early exit. Only A-Z fold, so a non-hex byte never matches a hex digit.
2026-10-16 07:30:48 +02:00
Dylan M. Taylor cddb6fa21c ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:54:15 -04:00
Higgs32584andClaude Opus 5.5 5c0e40784a rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:29:42 -04:00
Higgs32584 36236b908a Merge remote-tracking branch 'origin/main' into fix/4962-web-save-dirty 2026-10-15 22:28:59 -04:00
Higgs32584andClaude Opus 5.5 5f8d92f9f0 rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:27:09 -04:00
Higgs32584 ad620fba50 Merge remote-tracking branch 'origin/main' into fix/5884-delay-load-windows-data-pdf 2026-10-15 22:26:18 -04:00
Higgs32584andClaude Opus 5.5 c972acf98f rustfmt: format after merging main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:16:12 -04:00
Higgs32584andClaude Opus 5.5 76f29f67d5 rustfmt: format after merging main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:16:03 -04:00
Higgs32584 3eef8af459 Merge remote-tracking branch 'origin/main' into fix/help-search-arrows-space 2026-10-15 22:09:43 -04:00
Higgs32584 f46bd2a907 Merge remote-tracking branch 'origin/main' into fix/1056-path-selection-canvas-menu 2026-10-15 22:09:40 -04:00
Higgs32584andClaude Opus 5.5 7bc556ec02 Web: a requested download no longer marks the document saved (#4962)
The web writer only triggers an anchor download, which the user can cancel
and which reports nothing back, yet Save treated it as a confirmed write:
the document was marked clean and the leave-page guard disarmed. Services
gains `write_unconfirmed` (set by the web shell); a save through such a
writer names the document but keeps its changes unsaved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:54:09 -04:00
jonathnwxh f22d08da8c style: apply rustfmt to upstream smart-filter regression 2026-10-16 09:32:47 +08:00
David MannandClaude Opus 5.5 a9ba86898e PSD: Hue/Saturation smart filters open and save like Photoshop (#6267)
A Hue/Saturation smart filter (Fltr class HStr, filterID 'HStr') opened
as psd.unsupportedFilter, so re-rendering the smart object (replace
contents, relink) dropped it, and a Hue/Saturation smart filter made in
PhotoCraft was left out of PSD saves.

The new hue_sat_filter_map module maps Photoshop's layout (measured on a
Photoshop-saved file: presetKind, Clrz, Adjs = master Hst2 {H, Strt,
Lght} plus one Hst2 per edited range with LclR, BgnR, BgnS, EndS, EndR,
all longs) to image.adjustments.hueSaturation with the command's params,
and writes it back in that layout. Anything outside it (unknown keys,
duplicate or unknown ranges, values the dialog can't hold) stays an
opaque unsupported filter written back verbatim. smart_map.rs only gets
the dispatch hooks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 03:18:42 +02:00
David MannandClaude Opus 5.5 b59b0cd1d5 PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
A Perspective Warp smart filter (Fltr class perspectiveWarpTransform) fell
through to psd.unsupportedFilter, so a re-rendered smart object (e.g. after
Replace Contents) lost its perspective. Each quad now becomes a plane of
layer.smartObjects.perspectiveWarp (src = vertices, dst = warpedVertices),
and PSD save writes Photoshop's descriptor back: the original item with
only the geometry rewritten, or a new item in the layout Photoshop writes.
Inconsistent descriptors stay opaque, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 03:18:18 +02:00
jonathnwxh 7bff38c176 Merge remote-tracking branch 'origin/main' into repair/5413-upstream-schema 2026-10-16 09:13:54 +08:00
David MannandClaude Opus 5.5 0ce7e00fee Brush: the Dual Brush tip keeps its proportion at tiny main sizes (#4809)
Photoshop scales the Dual Brush diameter by the main size ratio, unrounded,
with a 5000 px cap and no lower floor: main 100 / dual 20 resized to 1 px
gives a 0.2 px dual tip, and back at 100 px it is 20 px again. PhotoCraft
already scaled the dual tip (#4856) but floored it at 0.5 px, so a trip to
1 px and back left a 50 px dual tip. Drop the floor (rendering bounds the
diameter itself), keep the cap, and leave a non-positive or NaN result alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 03:06:07 +02:00
Higgs32584andClaude Opus 5.5 cf3d26881d Windows: start without Windows.Data.Pdf.dll; delay-load it for printing (#5884)
winprint's PdfCreateRenderer made photocraft.exe and photocraft-cli.exe
import Windows.Data.Pdf.dll at load time, so Windows images without the
PDF component (NTLite-stripped, some N/LTSC builds) refused to start:
"windows.data.pdf.dll was not found". Both apps now delay-load it, and
PDF printing checks the DLL is installed first and reports an error
instead of aborting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:41:08 -04:00
itsF4LCON dedf89feae Type: move the caret test next to its helper, away from the file's end 2026-10-16 01:33:24 +02:00
Higgs32584andClaude Opus 5.5 8237d76170 Help search test: take the first Levels result and move the cursor to the end
"levels" now finds two results (Image › Adjustments › Levels… and the
Levels adjustment layer), and one → only reaches level|s, so the test
needs a second → before typing the space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:28:58 -04:00
itsF4LCON 67602017f8 Merge remote-tracking branch 'upstream/main' into feat/6278-replace-color-result
# Conflicts:
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-16 01:21:27 +02:00
David Mann ee0da9fbac Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
2026-10-16 01:13:28 +02:00
David Mann e6d636ce75 Merge main into feat/ui-217-4-animated-zoom
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
2026-10-16 01:02:02 +02:00
David Mann 9aa9064ce9 Merge main into feat/ui-217-4-save-in-background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
2026-10-16 00:54:47 +02:00
David Mann d94381edf0 Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/scorecard.md
2026-10-16 00:46:45 +02:00
David MannandClaude Opus 5.5 f4f1065785 Move tool: Ctrl-hover shows the distance to another layer (#4946)
With Smart Guides on and the Move tool, holding Ctrl (Cmd on the Mac) with no
button down and hovering another layer now draws magenta measurement lines
with labels between the selected layers' bounds and that layer's: the gap
between facing edges, or the distances between corresponding edges on an axis
where the two overlap. Over empty canvas, the Background or the selection
itself the distances run to the canvas edges. While an Alt-drag duplicates the
layers, the distance from the original to the copy shows.

Overlay only: no command, no history state. The hovered layer is the
Auto-Select hit test, cached per document revision, selection and pointer
pixel. Values use the ruler unit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 00:46:44 +02:00
itsF4LCON a76d3295ba Type: the text caret shows on a white background (part of #6085)
In the Photoshop themes the caret was a 1.5 pt white line with a 0.75 pt
black hairline inside, so on a white background only a faint grey line
showed. It is now a solid 1 pt black line inside a 3 pt white halo, readable
on any background, in every theme.
2026-10-16 00:38:48 +02:00
Higgs32584andClaude Opus 5.5 944d0cf722 Path Selection: the canvas right-click opens the path menu, as the Pen's does (#1056)
The Path Selection and Direct Selection tools had no canvas menu, so Make Selection, Fill and
Stroke Path, Free Transform Path, Delete Path and the combine and style rows needed the Pen tool
or the Paths panel. Like Photoshop, their right-click now opens the path menu for the selected
path, with the Pen menu's rows and the same enablement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:27:56 -04:00
itsF4LCON 0752cd8365 Merge remote-tracking branch 'upstream/main' into feat/6278-replace-color-result
# Conflicts:
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-16 00:27:03 +02:00
itsF4LCON c04da11bb3 Smart filters: rustfmt 2026-10-16 00:16:06 +02:00
itsF4LCON 8176b613ca Smart filters: the dialog's preview window shows the edited filter too (#6324)
The filter dialog's own preview window matched the canvas preview by the
filter's command, but an edited smart filter previews as
layer.smartFilter.setParams, so the window kept the unfiltered image.
smart_ui::preview_command names the command the preview runs.
2026-10-16 00:15:50 +02:00
itsF4LCON 8369b4b55c File › Open: a file type filter per format (#6337)
After All Formats the Open dialog listed only PhotoCraft, OpenRaster, Krita,
SVG and Paint.NET. Every format it reads now has its own filter (Photoshop,
Affinity, HEIF, PNG, JPEG, TIFF, Portable Bitmap, OpenEXR, Radiance HDR,
camera raw, …), and together they cover All Formats exactly.
2026-10-16 00:04:50 +02:00
itsF4LCON f71eb28f84 Replace Color: a Result swatch whose Color Picker sets the sliders (#6278)
Photoshop's Replace Color shows the replacement colour under the Hue,
Saturation and Lightness sliders; picking one in its Color Picker sets the
sliders so the sampled colour becomes it. tone::replace_color_result gives
the swatch (the sampled colour after the shift) and tone::replace_color_shift
the slider values for a picked colour.
2026-10-15 23:55:58 +02:00
David Mann bbd5ed2205 Merge remote-tracking branch 'origin/main' into fix/5559-bitmap-dialog
# 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
2026-10-15 23:52:42 +02:00
David Mann ce19e018b9 Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/ui-parity.md
2026-10-15 23:52:29 +02:00
itsF4LCON 0504331c51 Smart filters: an edited filter's dialog previews again (#6324)
Re-opening a smart filter dropped the dialog's Preview, since the live
preview ran the filter command and would have stacked a second copy. The
preview now runs layer.smartFilter.setParams on the proxy, as OK does, at
full size and reduced afterwards (the smart object renders again from its
source, which the proxy doesn't scale), so it shows the filter replaced in
its stack.
2026-10-15 23:46:54 +02:00
jonathnwxh f4842a04e8 Merge upstream multi-layer blending and preserve viewport refresh limits 2026-10-16 05:44:29 +08:00
itsF4LCON 4258089716 Replace Color: the sample eyedropper is armed when the dialog opens (#6279)
Photoshop's first click on the image after opening Replace Color samples the
colour. The dialog now opens (and resets) with its canvas eyedropper active;
the pipette button still turns it off and on.
2026-10-15 23:38:01 +02:00
Higgs32584andClaude Opus 5.5 a008842a85 Help › Search: ← / → and Space edit the field instead of driving the menu
While a text field in an open menu has the keyboard, Nav::keys leaves
← / → (cursor) and Space (a typed space) to it: ← / → no longer switch
Help to Window / File, and Space no longer runs the lit result. ↑ / ↓,
↩ and Esc are unchanged. Follow-up to #5996 / #6030 (letters).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:32:01 -04:00
David MannandClaude Opus 5.5 b43be75264 PSD: Wind, Extrude and Tiles smart filters open and save like Photoshop (#6314)
Wind, Extrude and Tiles applied as smart filters were dropped from PSD
saves with a warning, and the same filters in a Photoshop PSD opened only
as verbatim, uneditable entries. smart_map.rs now maps them both ways in
filterFX:

- Wind (`Wnd `, filterID 1466852384): `WndM` enum (`Wnd `/`Blst`/`Stgr`)
  <-> method; `Drct` enum: `Left` <-> fromRight (streaks extend to the
  left), `Rght` <-> fromLeft.
- Extrude (`Extr`, 1165522034): `ExtT` enum (`Blks`/`Pyrm`) <-> type,
  `ExtS`/`ExtD` long <-> size/depth, `ExtR` enum (`Rndm`/`LvlB`) <->
  depthMode, `ExtF`/`ExtM` bool <-> solidFrontFaces/maskIncompleteBlocks.
- Tiles (`Tls `, 1416393504): `TlNm`/`TlOf` long <-> count/maxOffset,
  `FlCl` enum (`FlBc`/`FlFr`/`FlIn`/`FlSm`) <-> fill; the entry's
  `FrgC`/`BckC` <-> foreground/background.

Our random seed has no PSD key and is not written. Missing keys, wrong
value types, out-of-range numbers and unknown enum values keep the entry
verbatim; params with no PSD equivalent are reported, never guessed.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back; each code also checked on a test image (pixels
compared). Scorecard FILE-215-2 note and file-format parity updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:30:11 +02:00
David MannandClaude Opus 5.5 badfd6df70 PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
Mezzotint and Color Halftone applied as smart filters were dropped from PSD
saves with a warning, and the same filters in a Photoshop PSD opened as
unsupported (verbatim) smart filters. smart_map.rs now maps them both ways:

- Mezzotint: filterID/class `Mztn`, `MztT` enum (FnDt, MdmD, GrnD, CrsD,
  ShrL, MdmL, LngL, ShSt, MdmS, LngS) <-> `type` (fineDots .. longStrokes).
  PhotoCraft's `seed` has no Photoshop equivalent and is not saved.
- Color Halftone: filterID/class `ClrH`, `Rds ` and `Ang1`-`Ang4` longs <->
  `maxRadius`, `channel1`-`channel4`, written within the dialog ranges
  (4..127, -360..360).

Unknown enum codes, wrong value types and out-of-range values are kept
verbatim as unsupported filters, never a panic.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back; codes confirmed by rendering each option.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:27:41 +02:00
David MannandClaude Opus 5.5 96bff5d7b6 PSD: Smart Sharpen and Dust & Scratches smart filters open and save like Photoshop (#6300)
Smart Sharpen (filter.sharpen.smartSharpen) and Dust & Scratches
(filter.noise.dustAndScratches) smart filters are now mapped both ways in
the PSD filterFX list instead of being dropped on save and opening as
uneditable entries.

- Smart Sharpen: filterID 1131 (numeric), stringID class smartSharpen;
  useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc,
  blur blurType enum. PhotoCraft removes a Gaussian blur, so it writes
  blur GsnB with useLegacy false. Entries with Lens Blur (Photoshop's
  default), Motion Blur (with Angl), legacy mode, any other key, wrong
  types/units or out-of-range values stay verbatim. presetKind
  (presetKindType) is accepted on open but not kept or written: the
  engine takes no such parameter, and Photoshop leaves it out of entries
  with explicit values.
- Dust & Scratches: filterID / class DstS; Rds and Thsh long (radius
  1..500, threshold 0..255; PhotoCraft params are rounded on write).

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Smart Sharpen default / Gaussian / Motion,
Dust & Scratches 1,0 and 4,20). Tests cover the measured shapes, option
round trips, malformed descriptors kept verbatim and hostile params.
Scorecard FILE-215-2 note and file-format parity doc updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:54:43 +02:00
David MannandClaude Opus 5.5 a99ad58591 PSD: Diffuse, Trace Contour, Crystallize and Pointillize smart filters open and save like Photoshop (#6302)
smart_map.rs maps four more filters both ways in filterFX:
- Diffuse: filterID `Dfs `, Fltr `Dfs ` { Md = DfsM enum Nrml / DrkO / LghO /
  anisotropic (long id) } <-> filter.stylize.diffuse `mode`.
- Trace Contour: `TrcC` { Lvl long 0-255, Edg = CntE enum Upr / Lwr }
  <-> filter.stylize.traceContour `level`, `edge`.
- Crystallize: `Crst` { ClSz long 3-300 } <-> filter.pixelate.crystallize.
- Pointillize: `Pntl` { ClSz long 3-300 } plus the entry's BckC as the
  `background` colour <-> filter.pixelate.pointillize.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Our random `seed` has no PSD key and is not
written. Missing keys, wrong value types, out-of-range numbers and unknown
enum values keep the entry verbatim; values with no PSD equivalent on save
are reported instead of guessed.

Scorecard FILE-215-2 note and docs/file-format-parity.md updated (stays
partial).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:48:16 +02:00
David MannandClaude Opus 5.5 7d36b417e5 PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen,
Sharpen More, Sharpen Edges and Solarize now map both ways in a smart
object's filterFX. Photoshop stores these filters with hasoptions false, no
Fltr descriptor and only the four-char filterID as a long (Blr , BlrM, Avrg,
Dspc, FndE, Fct , Frgm, Shrp, ShrM, ShrE, Slrz). PSD saves write exactly that
item (Nm, blendOptions, enab, hasoptions, FrgC, BckC, filterID) instead of
dropping the filter with a warning, and Photoshop's entries open as the
editable PhotoCraft filter instead of a verbatim pass-through. An entry that
carries a Fltr with settings, or an unknown or mistyped filterID, is still
kept verbatim.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back.

Scorecard FILE-215-2 note and docs/file-format-parity.md updated (25 filters
mapped).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:46:47 +02:00
David MannandClaude Opus 5.5 ef4e921408 PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298)
Map three more smart filters both ways in filterFX (smart_map.rs):

- Surface Blur: filterID 1135 (numeric), Fltr class `surfaceBlur`,
  `Rds ` UnitFloat #Pxl -> radius, `Thsh` long -> threshold.
- Smart Blur: filterID `SmrB`, `Rds ` / `Thsh` doubles, `SmBQ` enum
  (SBQL/SBQM/SBQH -> low/medium/high), `SmBM` enum
  (SBMN/SBME/SBMO -> normal/edgeOnly/overlayEdge).
- Radial Blur: filterID `RdlB`, `Amnt` long, `BlrM` enum (Spn/Zm ->
  spin/zoom), `BlrQ` enum (Drft/Gd/Bst -> draft/good/best).

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Every measured Fltr descriptor is written back
byte for byte (checked against the measured files locally; they are not
committed). Entries with missing keys, wrong types, out-of-range or NaN
values, unknown enum values or keys we don't model stay verbatim, as does
Radial Blur at its defaults (no Fltr stored). An off-centre Radial Blur
centre and unknown enum names are reported on save, not guessed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:46:42 +02:00
David MannandClaude Opus 5.5 d089908158 PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
Offset (filter.other.offset) and Custom (filter.other.custom) applied as
smart filters were left out of PSD saves with a warning, and the same
filters in a Photoshop PSD opened only as verbatim, non-editable entries.
smart_map.rs now maps both ways in filterFX:

- Offset: filterID/class Ofst; Hrzn and Vrtc as long (signed), Fl  as the
  FlMd enum: Wrp  = wrap, Rpt  = repeat, Bckg = transparent ("Set to
  Transparent" on a layer). Unknown enum values, wrong types or missing
  keys keep the entry verbatim.
- Custom: filterID/class Cstm; Scl  and Ofst as long, Mtrx as 25 longs,
  row-major 5 x 5. Written as the engine renders it (no kernel = identity,
  missing cells 0, values clamped to -999..999, 1..9999, -9999..9999);
  fractional values have no PSD equivalent and are reported. On open,
  values outside Photoshop's ranges or a matrix that is not 25 longs keep
  the entry verbatim.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Offset Wrap 0/0, Set to Background 12/-7,
Repeat 5/3; Custom default sharpen and a blur at Scale 2, Offset 10).
The measured files open as the editable filters and survive export and
re-import unchanged (checked locally, not committed).

Scorecard FILE-215-2 note and the file-format parity doc list the two
filters (still partial).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:45:46 +02:00
vangel76andClaude Opus 5.5 49438e71d2 Merge upstream/main into fix/5645-type-edit-keeps-subscript
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:12 +02:00
David Mann c84886adc2 WIP: PSD: Offset and Custom smart filters open and save like Photoshop (#6304) 2026-10-15 22:22:00 +02:00
David Mann 2058f2e180 Merge remote-tracking branch 'origin/main' into feat/ui-217-4-animated-zoom 2026-10-15 22:12:00 +02:00
David MannandClaude Opus 5.5 7f0e7cbee0 Zoom tool: holding the button zooms continuously with Animated Zoom on (#6210)
With Preferences > General > Animated Zoom on (the default), a Zoom tool
press held still past 0.3 s zooms continuously about the press point
(2 doublings a second, frame-rate independent, within the zoom_levels
range) until release; Alt zooms out. The release is then no click step.
A quick click still steps, Scrubby Zoom and the zoom rectangle are
unchanged, and with the preference off a hold is one click step.

The hold logic lives in the new zoom_hold.rs; canvas.rs only calls it.
general.animatedZoom leaves HIDDEN_UNTIL_IMPLEMENTED; prefs that do
nothing 25 of 166.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:11:45 +02:00
Muhammad Saad a4cdc63d88 Retouch: admit the rendered stroke extent, not an estimate (#4961, #6110)
Review of f116c32 was right that the guard did not establish the bound it
advertised. reject_bounds ran on the value stroke_coverage() returned, and that
function calls dense_coverage() internally, so the dense Vec was already
allocated by the time it was checked. reject_huge_stroke, the only check that
ran first, estimated from the raw points and half the tip, which brush dynamics
exceed: Scattering offsets a dab centre by its amount times the tip size.

Split rendering from dense extraction. stroke_coverage_within renders the stroke
into the renderer's tiles, which hold only what the dabs touch, reads the extent
once every dab is placed and refuses before allocating the width x height
buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke
command goes through it, so the bound now holds whatever the dynamics do.
reject_bounds is removed; it could not do the job it was named for.

spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns
Result<Rect> now. reject_huge_stroke stays as the cheap early-out and pads by
the scatter reach and the dual-brush radius so it no longer falls short.

The allowance is unchanged, so no stroke that worked before starts failing.

Tests: coverage_within takes the budget as a parameter, and a scattering brush
proves the rendered extent exceeds what the points alone suggest, is refused one
pixel under it and fits at it. Every command's refusal now asserts the pixels
and the history step count are untouched, and the ordinary stroke uses a valid
fixture and must succeed rather than merely avoid the size error.
2026-10-15 23:50:55 +04:00
David Mann 22f1d694c9 Merge remote-tracking branch 'origin/main' into feat/ui-217-4-animated-zoom 2026-10-15 21:35:40 +02:00
David MannandClaude Opus 5.5 e8f50f69c3 scorecard: TOOL-214-4 note and the pasteboard menu row catch up with main
Dragging layers onto another document's tab landed in #851 (#589), and the
pasteboard colour menu in #2633 (#2462); the TOOL-214-4 note and the
context-menu parity row still listed both as open.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 21:17:33 +02:00
David Mann 0aed96c7fd Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
# Conflicts:
#	docs/ui-parity.md
2026-10-15 21:17:33 +02:00
artman67andClaude Opus 5.5 c4de02978b Menu bar: releasing Alt after Alt+scroll no longer focuses the bar, so Space can't run File > New
Releasing Alt counted as a tap (keyboard focus to the menu bar, File lit) unless a key, text or
pointer-button press came in between. A mouse-wheel event was not on that list, so zooming with
Alt+wheel and letting go of Alt focused the bar; Space then opened File with New... lit, and a held
Space's key repeat ran it. A wheel event, or the window losing focus while Alt is down (Alt went
to a desktop shortcut), now cancels the tap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:14:15 -04:00
jonathnwxh 0caa7ed54a Merge upstream CI ordering while retaining clipboard regression jobs 2026-10-16 01:53:19 +08:00
David MannandClaude Opus 5.5 a1837706ee Performance: a cancelled Gaussian Blur job stops within budget (#6205)
Cancelling a 24 MP Gaussian Blur r 50 job took up to ~400 ms (P25, budget
200 ms max). Stage timings showed two stretches without a cancel check:

- The transparency-lock pass (keep_alpha) that puts a Background layer's
  alpha back after the filter ran serially over every changed tile, about
  400-450 ms on 24 MP, with no cancel check. It now fixes the tiles in
  parallel, each in its own scratch surface, and checks for cancellation
  before each tile. Results are bit-identical to the serial pass (tested
  against it at 8, 16 and 32-bit).
- Box-blur column strips checked only before each strip; with ~31 strips
  per band and 32 threads one strip is tens of ms. line_passes now checks
  between passes.

P25 max: 337-399 ms -> 13-44 ms over five runs. The uncancelled job is
also faster: p50 about 690 -> 400 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 19:17:07 +02:00
Nigel Ihza Firdausy 9a25a8a390 style(svg): format expanded conversion state initializer 2026-10-16 00:14:38 +07:00
Nigel Ihza Firdausy 159cd42ae9 fix(svg): honor U8 storage budget and avoid charging rejected layers
Preflight oversized pixmaps and conversion peak before allocating.
Only charge the U8 retained-surface allowance after render succeeds.
Clarify the warning and cover later small raster groups with regressions.
Merge upstream main to refresh the pull request branch.
2026-10-16 00:13:40 +07:00
David MannandClaude Opus 5.5 6f1984d126 Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212)
Tool options and the selected tool were already remembered (tool_memory, #2814), but
each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that
owned the session brush restarted as the Brush, so after quitting with the Eraser its
brush became the Brush's.

- Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and
  on exit, restored at launch with stored tips as references (loaded on swap).
- brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored
  brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow,
  smoothing).
- tool_memory::sanitize clamps restored tool options to their options-bar ranges.
- Reset Tool / Reset All Tools clear the persisted state through the same path.
- Scorecard UI-217-1 note updated (stays partial: font recents).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:59:22 +02:00
David MannandClaude Opus 5.5 a3cbc880e6 Preferences: Save in Background off saves in the foreground (#6207)
Preferences > File Handling > Save in Background was never read. Saves
already run as a background job; the setting now picks how the app
waits for it, as in Photoshop:

- On (default): you keep working while the file is written; progress
  shows in the document tab and the status bar. The modal progress
  dialog no longer appears for these saves.
- Off: the same job, worker and atomic write, but the modal progress
  dialog shows at once and no shortcut reaches the app until the file
  is complete; a close or quit asked for meanwhile waits for it.

The setting now shows in Edit > Preferences. Prefs audit: settings that
do nothing 26 -> 25 of 166.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:48:58 +02:00
David Mann 2a31c28c80 WIP: Zoom tool: hold to zoom continuously with Animated Zoom (#6210) 2026-10-15 18:12:32 +02:00
jonathnwxh ce0ba03572 Use the actual pixel grid Show preference in regression 2026-10-15 23:56:30 +08:00
jonathnwxh 173e7fd6bd Test successful Object Selection restoring hidden Extras 2026-10-15 23:51:18 +08:00
jonathnwxh 2cea742a00 Cover remaining interactive selection routes and preserve no-op hiding 2026-10-15 23:50:21 +08:00
jonathnwxh 6fc1004b50 Cover Extras restoration through actual layer thumbnail clicks 2026-10-15 23:47:34 +08:00
jonathnwxh 6bef23a715 Reveal Extras when interactive selection commands succeed 2026-10-15 23:45:34 +08:00
ersoynarin-code 7d42b5c838 fix(perf): validate regression minimum threshold 2026-10-15 18:38:34 +03:00
jonathnwxh 67a2938cab Guard intentional hidden selections against failures and unrelated edits 2026-10-15 23:32:18 +08:00
jonathnwxh 0a608e63db Cover marquee pointer gesture and subsequent explicit hide 2026-10-15 23:31:23 +08:00
jonathnwxh 14ceeb927e Reproduce new selections remaining hidden after Extras is disabled 2026-10-15 23:27:15 +08:00
David MannandClaude Opus 5.5 24a77a18c0 Shapes: a soft shape's transform frame follows its path (#5846)
A shape below 100 % Density renders over the whole canvas, and a Feather
grows its pixels, so Free Transform, the Move tool's transform controls,
snapping and Properties > Transform framed the canvas or the blur. As in
Photoshop the frame now follows the path and stroke whatever the mask
Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds
without the soft reach) feeds whole_layer_bounds and the Properties
Transform fields. Rendering keeps the soft bounds; other layers and hard
shapes are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 17:08:05 +02:00
David MannandClaude Opus 5.5 442987528f Merge main into fix/4017-huge-new-document
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:52:49 +02:00
David MannandClaude Opus 5.5 eeb955649b Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:52:46 +02:00
David MannandClaude Opus 5.5 275576543f Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:38:01 +02:00
David MannandClaude Opus 5.5 8c184c78d5 Merge main into fix/smart-filter-last-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:30:58 +02:00
David MannandClaude Opus 5.5 031e0de625 Merge main into feat/file-215-18-export-resample
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:29:28 +02:00
David MannandClaude Opus 5.5 c7bd182e91 Merge main into fix/lens-filters-smart-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:29:14 +02:00
David MannandClaude Opus 5.5 5b0d7263e6 Merge main into fix/count-delete-group-active
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:25:43 +02:00
David MannandClaude Opus 5.5 9203a6857a Merge main into fix/adjust-smart-object-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:23:49 +02:00
David MannandClaude Opus 5.5 01184b7083 Merge main into fix/video-fps-validate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:20:48 +02:00
David MannandClaude Opus 5.5 674340230c Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:16:28 +02:00
jonathnwxh e0ddefe7e0 test(text): retain raw TySh as the document importer does 2026-10-15 22:07:31 +08:00
jonathnwxh 2656f8db3d fix(text): preserve imported font records during text and style edits 2026-10-15 22:04:27 +08:00
jonathnwxh 70ffad5a56 test(text): retain imported font metadata after text edits 2026-10-15 22:01:09 +08:00
David MannandClaude Opus 5.5 daaeeda7c7 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:00:11 +02:00
David Mann ecaa81b42a Merge branch 'main' into perf/p33-cmyk-dab 2026-10-15 15:57:57 +02:00
David Mann e240016fe4 Merge branch 'main' into fix/smart-filter-last-mask 2026-10-15 15:56:58 +02:00
David Mann 8b5ddbba91 Merge branch 'main' into fix/lens-filters-smart-locks 2026-10-15 15:56:52 +02:00
David Mann 3182414717 Merge branch 'main' into fix/count-delete-group-active 2026-10-15 15:55:59 +02:00
David Mann 6717d228a1 Merge branch 'main' into fix/adjust-smart-object-locks 2026-10-15 15:55:47 +02:00
David Mann ff26781bab Merge branch 'main' into fix/video-fps-validate 2026-10-15 15:55:38 +02:00
David Mann b693494237 Merge branch 'main' into fix/mask-from-transparency-bg-depth 2026-10-15 15:55:29 +02:00
David Mann 9fa9f86c48 Merge branch 'main' into fix/fill-layer-angle-finite 2026-10-15 15:55:12 +02:00
David Mann 8c524c9295 Merge branch 'main' into fix/lock-transparency-atop 2026-10-15 15:54:57 +02:00
David Mann 830676cc7d Merge branch 'main' into fix/multichannel-alpha 2026-10-15 15:54:46 +02:00
jonathanwxh-cell 6b3a6ffd8b Document conservative blend preview coverage
Explain why clip_damage replaces the covered intersection, including an
empty one, while retaining the affected region for uncovered to recompute.
This is a comment-only follow-up to review on storytold/photocraft#5413.

Existing controlled 24 MP release measurement: median refresh/upload-
submission time was 734.677 ms for the full 6000x4000 region and 59.641 ms
for a 1280x720 viewport, five measured samples per region after warmup.
This compares regions in the same candidate binary on CPU fallback with
Vulkan llvmpipe, not two revisions or physical-GPU completion latency.
Evidence: https://github.com/jonathanwxh-cell/photocraft/actions/runs/38512473874
2026-10-15 21:52:28 +08:00
Michael Higgins 310c489996 Merge branch 'storytold:main' into fix/5799-batch-same-format-raw 2026-10-15 09:39:09 -04:00
Michael Higgins 0c959f10d4 Merge branch 'storytold:main' into fix/5713-smoothing-pressure-follows-brush 2026-10-15 09:37:28 -04:00
David MannandClaude Opus 5.5 2ab37e7e55 Scorecard: regenerate after merging main (TOOL-214-12 done, #5846)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:06:00 +02:00
David MannandClaude Opus 5.5 8435a4586f History Brush source: snapshot sources, size check, purge and budget fixes
- Edit > Purge > Histories resets a state source to the default instead of
  leaving every document without one (History::clear).
- The pinned source state counts against the history byte budget first; the
  newest states fill what remains, so trimming stays within max_bytes.
- The History Brush and Fill > History refuse a source of another canvas size
  (after Crop, Canvas Size or Image Size) instead of painting shifted pixels.
- Snapshots can be the source: history.setSource {snapshot}, a source column on
  snapshot rows, HistorySource::State|Snapshot in the engine; deleting the
  snapshot leaves no source, Purge > Histories keeps it.
- paint.historyBrush and Fill > History take History panel rows for `state`
  (redo rows included), as history.setSource does, and accept `snapshot`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:58:10 +02:00
David MannandClaude Opus 5.5 e23840f516 History panel menu: review fixes (floating steps, cleared-state memory, clean undo, New Snapshot)
- Step Backward in the History panel puts a floating selection back like Edit > Undo; Step
  Forward and Edit > Redo put it back and step instead of dropping it first (which recorded a
  Move and wiped the redo states); with nothing to step to the piece keeps floating.
- Memory figures and the byte budget count the states Clear History keeps for its undo.
- Undoing Clear History keeps a saved document clean.
- New Snapshot... in the panel menu runs history.newSnapshot.
- Tests: Clear on one document leaves the others alone, and each fix above.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:57:54 +02:00
Muhammad Saad f116c3230a Retouch: guard every stroke command, not only Pattern Stamp (#6110)
Pattern Stamp was the only retouch command that refused a stroke it could not
render. Clone Stamp, Healing Brush, Spot Healing and Remove all reach the same
dense conversion: coverage is accumulated in tiles, but dense_coverage()
materialises the whole bounding box, and the retouch paths read a Region over
that box for every channel. The box comes from the stroke's own points, which
are validated against MAX_COORD rather than the document, so it is not bounded
by the canvas. Both allocations go through the infallible vec!, which aborts
instead of returning an error.

With the guards now shared in retouch_cmds, apply them to the rest:
reject_huge_stroke before anything is rendered, and reject_bounds before the
dense region read where the command can return one. spot_heal_surface returns a
Rect, so Spot Healing charges the stroke in the command; Remove builds a list,
so each stroke is charged as it is parsed.

The allowance is unchanged, so no stroke that worked before starts failing.

Test: every retouch stroke refuses the oversized box, leaves the pixels alone,
and still accepts a stroke across the whole canvas.
2026-10-15 15:47:38 +04:00
David Mann ce5af000f2 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu 2026-10-15 13:03:07 +02:00
David Mann 2bc49845a7 Merge remote-tracking branch 'origin/main' into fix/history-brush-source 2026-10-15 13:03:04 +02:00
itsF4LCON d065c874e5 Merge remote-tracking branch 'upstream/main' into fix/5996-help-search-mnemonics 2026-10-15 12:58:26 +02:00
David Mann 43d8572c9f Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-15 12:12:20 +02:00
David Mann 9e532faa3a Merge remote-tracking branch 'origin/main' into fix/5559-bitmap-dialog
# 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
2026-10-15 11:55:20 +02:00
David MannandClaude Opus 5.5 24a86a3f6f Image: Bitmap dialog with Output resolution and per-method settings (#5559)
Image > Mode > Bitmap now opens a dialog laid out like Photoshop's:
Resolution (Input, read-only; Output in pixels/inch or pixels/centimeter),
Method (50% Threshold, Pattern Dither, Diffusion Dither, Halftone Screen,
Custom Pattern) with a pattern picker enabled for Custom Pattern, and the
Halftone Screen settings (Frequency, Angle, Shape) shown only for that method.

The image.mode.bitmap command gains `resolution` (resamples to the Output
resolution keeping the print size, then sets it) and the `customPattern`
method (threshold map ranked from the pattern's lightness, tiled from the
top-left). The halftone screen is laid out at the output resolution. The
live preview keeps the canvas size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 11:54:35 +02:00
David MannandClaude Opus 5.5 869b2556d3 io: drop the Photoshop version from the gradient opacity comments (#1954)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 11:49:50 +02:00
David Mann 09f782b1ff Merge remote-tracking branch 'origin/main' into fix/1954-overlay-opacity-stops 2026-10-15 11:37:40 +02:00
Finn Newick 093c6836c8 Merge remote-tracking branch 'origin/main' into refactor/engine-drop-note-insert 2026-10-15 10:37:03 +01:00
jonathnwxh e338761de9 Merge upstream CI test and web packaging fixes 2026-10-15 17:04:54 +08:00
Persi 5ad0b92aac Merge branch 'main' into psd-background-opaque-check 2026-10-15 10:09:43 +02:00
Brian TorresandClaude Sonnet 5.5 827e4e7094 Codecs: cap what tiff_exif::read copies from overlapping EXIF values (#5982)
An entry's value only has to lie inside the file, so many Exif entries can
point at the same bytes: a 62 KB TIFF with 1,000 such entries made the reader
copy 50 MB (and the block writer copy it again), on every TIFF import and on
export of a document carrying an EXIF block.

read() now has a copy budget shared by IFD0's descriptive tags and the GPS and
Exif directories: at most 4 MiB, and never more than the stream's own length,
since the values of a well-formed stream are distinct bytes of it. An entry
that does not fit is left out and later, smaller ones are still kept; the
small directories are read first so a huge maker note cannot crowd out the
GPS position. Maker notes of 200 KB and 1 MB round-trip as before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-15 01:01:37 -07:00
Nigel Ihza Firdausy 35b4b30f2d style(path): keep regression cases consistently spaced 2026-10-15 15:00:44 +07:00
Nigel Ihza Firdausy caceb01dda test(path): cover invalid Make Selection params and open-path modes 2026-10-15 14:59:18 +07:00
Nigel Ihza Firdausy ec2627dbbb fix(path): validate Make Selection parameters before rasterization 2026-10-15 14:57:59 +07:00
itsF4LCON 116a607697 Window › Arrange: tiles keep their documents, and every tile has rulers (#6017)
With documents tiled side by side, a click into the other one moved it
to the first tile, swapping the two, and only the active tile showed
rulers. Each document now keeps its tile (with more documents than
tiles, the shown run slides just enough to include the active one), and
inactive tiles and floating windows draw their own document's rulers.
Guides and the zero point are still dragged in the active view.
2026-10-15 09:41:16 +02:00
itsF4LCON 510636b19c Type: Korean paragraph text wraps between words, not syllables (#5959)
Unicode's line-breaking rules allow a break between any two Hangul
syllables, so Korean box text split words (설 / 정이나). Korean is set
with breaks at the spaces between words, as Photoshop does: runs of
Hangul now lay out with WordBreak::KeepAll. Chinese and Japanese keep
breaking between characters.
2026-10-15 09:34:09 +02:00
xsvm 9009683671 refactor(gpu): bevel fields are asked for, windowed and checked as one pair
- the bevel program asks for both fields with B::bevel_fields, so they always come with the
  same reach;
- field and bevel_fields share one windowing (fields_in_windows: one window, or bands on wasm),
  so wasm pairs them per band instead of taking a separate path;
- compose's bevel_height and bevel_distance_fields share bevel_dists;
- sync_fx recomputes the pair together and returns Unsupported when the two are not over the
  same rectangle at the same reach, instead of falling back to one at a time.

Test: paired_bevel_fields_equal_the_fields_one_at_a_time also checks the banded path.
2026-10-15 15:34:03 +08:00
xsvm 5a93e93a44 perf(gpu): a chisel bevel's two fields find their sharp edges once
On the GPU canvas a bevel size change recomputes BevelInside and BevelOutside on the CPU, one
distance_field call each, and each call ran sharp_edges (edge pixels and refined normals) on the
same window again; the CPU compositor already shares them (bevel_height). sharp_edges was ~17% of
the CPU time of those size changes.

When both fields are recomputed over the same rectangle at the same reach (a size change, a new
shape), sync_fx now computes them together: compose::effects::bevel_distance_fields finds the
sharp edges once and returns both. Same window, same functions, so both fields are bit for bit
the ones computed one at a time; the chamfer fields, wasm and a single bevel field keep the old
path.

Test: paired_bevel_fields_equal_the_fields_one_at_a_time (reach 3, 9, 40; whole region and two
parts; bit-equal to field() of each kind).
2026-10-15 15:34:03 +08:00
xsvm d743c2532d perf(compose): bevel maps shade, chisel and mix rows on threads, results unchanged
A chisel bevel size change rebuilds the bevel maps of the whole layer region. The distance
fields already run on threads (#2119), but the passes after them still ran on one thread over
every pixel: the chisel height from the two distances, the texture term, the highlight/shadow
shading (once, or twice for emboss styles) and the emboss inside/outside mix.

Each of these computes a pixel from its own inputs only (the height map, the shape, the
distances), so they now go row by row through par_units / par_fill like the fields: the same
arithmetic in the same order per pixel, so the maps are bit for bit the old ones whatever the
thread count. Maps under 65536 pixels and wasm stay on the calling thread.

Test: bevel_maps_do_not_depend_on_the_threads (4 shapes x 5 styles x 3 techniques, with soften
and a texture, 1 vs 2, 5 and 16 threads, every map bit-equal).
2026-10-15 15:34:02 +08:00
itsF4LCON 494308a038 Image Size: Indexed Color resamples with Nearest Neighbor only (#6019)
An Indexed Color document holds only its colour table's colours, and a
save quantises to them. Interpolating methods showed blended colours on
screen that the saved file then lost. image.imageSize now resamples an
Indexed document with Nearest Neighbor whatever method it is given, and
the dialog offers only Automatic and Nearest Neighbor for one.
2026-10-15 09:28:55 +02:00
itsF4LCON 5b39b2b3af Help › Search: typed letters go to the field, not to menu mnemonics (#5996)
In an open menu a plain letter runs the row with that mnemonic. Typing
into Help › Search did too: "S" ran System Info and "A" opened the
ArtCraft website, so only "De" of "Deselect" could be typed. Mnemonics
now wait while a text field in the menu has the keyboard.
2026-10-15 09:18:19 +02:00
Christian Gleissner 181bd3be39 fix(count): reject unknown target groups before adding markers 2026-10-15 07:14:15 +01:00
Persi 11e4b40735 PSD export: check the Background's opacity from its tiles 2026-10-15 07:50:38 +02:00
Muhammad Saad 205c9c1737 History Brush: bound the stroke before its dense buffers (#4961)
The History Brush renders its coverage and reads its source region densely
over the stroke's bounds. Coverage is accumulated in tiles, so a thin stroke
holds only the tiles it touches, but dense_coverage() materialises the whole
bounding box and Region::read allocates that box again for every channel.
Neither is admitted first, and both go through the infallible vec!, which
aborts the process instead of returning an error.

Pattern Stamp already refuses a stroke it cannot render: reject_huge_stroke
bounds it from the points and the brush size before anything is rendered,
reject_bounds checks the rendered box, and MAX_STROKE_PIXELS (8192 squared)
leaves room for a stroke that runs corner to corner across a 24-36 MP canvas.
The three were private to that module.

Move them to the shared retouch_cmds module and apply them in the History
Brush: reject_huge_stroke before the source surface is cloned or any coverage
is rendered, reject_bounds before the dense source read. The budget is
unchanged, so every stroke that worked before still works.

Clone Stamp, Healing Brush, Spot Healing and Remove reach the same conversion
and are left for a follow-up.

Test: a stroke whose box dwarfs the canvas is refused with the size error and
leaves the pixels as they were, while one that runs corner to corner across
the canvas still paints, at 8, 16 and 32 bits.
2026-10-15 09:39:25 +04:00
Bram HeerinkandClaude Opus 5.5 f49564d281 PSD: keep guide resources 1092/1097 only while they match the guides (#5966)
Photoshop writes image resource 1092 (guides v2: 1032's guides plus a
colour per guide) and 1097 (one GUID per guide) next to 1032. Export
regenerated 1032 from the document but wrote 1092 and 1097 back verbatim,
so after Clear Guides, New Guide, a move or a recolour the file carried
two guide lists that disagree.

Both are now written back only while 1092 still describes the document's
guides (positions, directions and colours) and 1097 has as many entries;
otherwise both are left out and the file carries 1032 alone, as files
from before guides v2 do. Import takes the guides' own colours from 1092
(Photoshop's default Cyan stays with the preference colour), the read
half of #5967.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:05:48 +07:00
dotCooCoo f8cc7fca50 Brushes: importing an .abr file numbers repeated brush names in linear time
brush.presets.importAbr named each imported preset with unique, which
compared a candidate name with the names of the presets one by one and
tried "Name", "Name 2", "Name 3" and so on until one was free. A
version 1 or 2 brush with a computed tip and no name is named after
its tip ("9 px Round"), so a file of n such brushes of one diameter
cost about n^3/6 string comparisons. In a test build, importing 10,000
of them took 25 minutes (22 minutes of CPU time).

The import now builds the set of preset names once and keeps the next
number to try for each repeated name, as swatches.import does for
swatch and group names. Names still compare case-insensitively, and
every preset gets the same name as before. brush.presets.importImage
names its one preset with the same code.

repeated_brush_names_are_numbered_in_linear_time imports 10,000 such
brushes on a thread of its own and fails when the import takes more
than 20 seconds. repeated_brush_names_take_the_first_free_number
checks the names when the file repeats a name in other letter cases,
when a preset already holds one of the numbers, when the file has a
name that the import already gave to an earlier brush, and when a
name clashes with a built-in preset.
2026-10-14 20:25:51 -07:00
jonathanwxh-cell cc5971ff5b ci: run clipboard regressions independently 2026-10-15 10:07:54 +08:00
David MannandClaude Opus 5.5 6bb45c8d5b Shapes: Density and Feather on shape layers (#5846)
A shape layer's path is its vector mask in Photoshop (Properties > Masks),
with its own vector-mask density and feather. PhotoCraft's ShapeLayer had
neither, so soft shapes could not be made, and stroked soft shapes from PSDs
lost both on import.

- doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px,
  default 0).
- vector: the shape renderer applies them to the fill as a soft vector mask
  (fill shows beyond the path at 1 - density; the feather is the vector
  mask's Gaussian), so the shape's own pixels, the GPU (which samples them)
  and the stroke/fill split all follow. The stroke stays crisp. The feather
  blur moves from compose::masks to vector::feather and both share it and the
  feathered area, so a shape and an equal vector mask render the same pixels.
- compose: soft shapes' bounds reach their fill, their layer effects follow
  the soft alpha, and the split cache key includes density and feather.
- engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one
  history step (path/enabled/linked/invert are refused there); density is now
  validated (finite 0..100) instead of clamped. `layer.vectorMask.info`
  reports a shape's values.
- ui: Properties shows the Vector Mask Density and Feather sliders for a
  selected shape layer in both themes (live drag, one undo per gesture).
- .pcraft: manifest and convert carry both with serde defaults (older files
  load at 100 % and 0 px).
- PSD: export writes them as the vector density/feather mask parameters;
  import reads them into the shape (re-rendered, as the stored pixels lack the
  fill beyond the path). Soft unstroked solid/gradient shapes now import as
  shapes instead of fill layers with a vector mask; pattern-filled ones stay
  fill layers.

Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72:
ours is within 1 level of every measured value (8, 16 and 32 bits);
density 50 % shows 127/128 beyond the path and 0 inside.

Scorecard: TOOL-214-12 done.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 03:07:46 +02:00
David MannandClaude Opus 5.5 732b977dc4 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:41:07 +02:00
David MannandClaude Opus 5.5 599a2304ed History panel menu: Delete removes the selected snapshot, as the trash does
Main's History panel can select a snapshot, and its trash then deletes that
snapshot (#5324). The panel menu's Delete still deleted the current state.
It now follows the same selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:41:06 +02:00
David MannandClaude Opus 5.5 a0c2c1992f docs: ui-parity marks the modifiers-at-press row done, as BUG-208-4 is
Main has the marquee Shift+Alt intersect test (#3624), so the row no longer
says it is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:36:30 +02:00
David MannandClaude Opus 5.5 00959b3549 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:35:04 +02:00
David MannandClaude Opus 5.5 1ce9a87b6b Effects: Gradient Overlay transparency follows Smoothness, midpoints and method (#1954)
Gradient layer effects baked only their colour stops; the opacity stops came
through raw, so Smoothness, opacity midpoints and the interpolation method
never reached the transparency ramp. Gradient fill layers applied Smoothness
and midpoints but always with Classic.

Both now share one bake (gradient_bake::bake_opacity). Measured on scripted
Photoshop 25.1 (overlays and fill layers render identical pixels): Linear and
Perceptual also bend the opacity curve (stops enter the method's space as
sRGB grey levels, the result leaves as y^0.65), fitted within 0.015 of every
8-bit sample. Smooth keeps Classic's opacity curve (not measurable on 25.1).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:18:12 +02:00
jonathanwxh-cell 3de03f48bf Keep unexported clipboard copies usable when native reads fail 2026-10-15 08:16:22 +08:00
David MannandClaude Opus 5.5 8601a7b170 docs: resolve the conflict markers left in the main merge
The merge of main (5a4df1a3) committed unresolved conflict markers in the
status lines of gaps.md, target-app-parity.md and ui-parity.md and in the
Type/Vector rows of target-app-parity.md. Keep main's text and add this
PR's own items back: G8 Warp Text preview done (TYPE-218-7), the Type
row's evidence, and the selection-modifier and Alt-wheel rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:05:26 +02:00
Michael Higgins 16a7ad1a02 Merge branch 'storytold:main' into fix/5713-smoothing-pressure-follows-brush 2026-10-14 20:04:26 -04:00
David MannandClaude Opus 5.5 17881fc2ad Merge main into fix/mask-from-transparency-bg-depth
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:53:57 +02:00
David MannandClaude Opus 5.5 a8db6dd100 Export As: Resample menu, incl. Nearest Neighbor (#5838)
Before this change, Export As always resampled the exported copy with Bicubic, and the preview
used Bilinear. Pixel art and screenshots could not be enlarged with hard edges. Photoshop's
Export As has a Resample menu next to Width/Height/Scale for this.

- engine export_preset_cmds: RESAMPLE lists Photoshop's six choices (Bicubic Automatic, the
  default, then Bicubic, Bicubic Smoother, Bicubic Sharper, Bilinear and Nearest Neighbor)
  and export_resample() checks a value. An unknown value is now an error. Before, Image Size
  quietly treated it as Bicubic.
- Export presets save and restore resample. Older presets load with the default.
- layer.exportAs takes a `resample` param, checked even at 100 %.
- Export As dialog: a Resample dropdown under Image Size. It is remembered with the other
  settings, and a bad saved value falls back to the default. export_document passes the
  choice to image.imageSize for the exported copy only.
- Preview: the proxy keeps its fast Bilinear path, except for Nearest Neighbor, which is
  just as cheap and is what the user wants to see. The preview texture also uses NEAREST
  magnification then, so small exports look blocky instead of blurred.
- Translations for the three new labels in all 20 catalogues.
- Scorecard FILE-215-18 is now done. Also fixes a stale doc comment on VectorMask::feather,
  because the compositor does feather vector masks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:53:42 +02:00
Michael Higgins 0f75495246 Merge branch 'storytold:main' into fix/5799-batch-same-format-raw 2026-10-14 19:50:22 -04:00
David MannandClaude Opus 5.5 f471db33a7 Merge main into fix/video-fps-validate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:22:42 +02:00
David MannandClaude Opus 5.5 8003f3b20a Merge main into fix/adjust-smart-object-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:19:31 +02:00
David MannandClaude Opus 5.5 22c0351ffc Merge main into fix/count-delete-group-active
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:10:54 +02:00
David MannandClaude Opus 5.5 e333c9c4be Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:03:29 +02:00
David MannandClaude Opus 5.5 91bffdec0b Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:57:30 +02:00
Higgs32584andClaude Opus 5.5 2750809257 Batch: Format "Same" saves camera raws as PSD instead of failing (#5799)
With Format "Same" every output took its input's extension, so a folder of
ARW/CR2/NEF/DNG files tried to write raws, which PhotoCraft can't, and every
file failed. Raws and other import-only inputs (PDN, Krita, Affinity) are now
saved as PSD; other inputs keep their format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:56:28 -04:00
David MannandClaude Opus 5.5 78692cfb28 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:46:51 +02:00
David MannandClaude Opus 5.5 b2e98a7d20 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:42:55 +02:00
David MannandClaude Opus 5.5 683e06aafa Merge main into fix/smart-filter-last-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:40:03 +02:00
David MannandClaude Opus 5.5 0d32619ed8 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:39:45 +02:00
David MannandClaude Opus 5.5 d201fbe056 Merge main into fix/lens-filters-smart-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:33:46 +02:00
David MannandClaude Opus 5.5 27c4c56326 Merge main into fix/4017-huge-new-document
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:32:33 +02:00
David MannandClaude Opus 5.5 5a4df1a363 Merge main; BUG-208-4 done, rows re-checked against main
Re-checked every row this PR flips against current main: the cited
tests still exist and none of the rows was flipped meanwhile.
BUG-208-4 is done too: shift_alt_before_the_drag_intersects_the_selection
(marquee_tests.rs) covers the marquee Shift+Alt intersect.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:29:48 +02:00
Finn NewickandClaude Opus 5.5 fbaf6795cf refactor(engine): drop the second damage pass after single-edit duplicates and pastes
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:50:34 +01:00
David MannandClaude Opus 5.5 667a14d945 PSD Multichannel: alpha channels are stored as-is, not as ink (#5790)
A Multichannel PSD stores ink planes as tone (0 = solid ink), but the
planes DisplayInfo (1077) marks as alpha-type (kind 0/1) are stored as-is,
like alpha channels in every other mode. Import inverted every plane and
export wrote every channel inverted, so a white (selected) alpha imported
as 0 and RGB -> Multichannel -> save wrote alpha channels black.

Measured with a file Photoshop saved itself: a new 8x4 8-bit white RGB
document plus a "selected areas" alpha channel, white left half / black
right half, converted to Multichannel and saved as PSD. Merged planes:
Cyan, Magenta, Yellow = 255 everywhere (ink-inverted: no ink on white
paper); alpha = 255 255 255 255 0 0 0 0 per row (as-is, 255 = selected).
DisplayInfo kinds: 2, 2, 2, 0.

- io: after DisplayInfo, flip alpha-type channels back; on export invert
  only spot channels.
- compose: an alpha-type channel prints as black ink of 1 - value, so the
  composite of a Multichannel file is unchanged.

Tests: a kind-0 alpha imports as stored (white = 1.0) with the spot plane
still read as ink, the composite and the byte-for-byte round trip hold
(8 and 16 bit); a full alpha exports as a max plane next to a solid ink
stored 0. The #4031 composite check now compares against a black ink of
the inverted alpha, within f32 rounding; the compose unit test uses the
as-is polarity for its alpha channel.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:20:06 +02:00
David MannandClaude Opus 5.5 5781847da3 Paint: Lock Transparency blends source-atop on semi-transparent pixels (#5783)
With alpha kept (Lock Transparent Pixels, Fill's Preserve Transparency),
the brush, mixer, Fill, Gradient and Paint Bucket composited source-over
and then restored the old alpha, so a half-transparent pixel took the new
colour by as / ao instead of as. They now blend source-atop, as Photoshop
does: Co = (1-as)*Cb + as*B(Cb,Cs), alpha unchanged. New shared helper
blend::composite_atop; over_native's lock branch lerps by the source alpha.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:15:43 +02:00
vangel76andClaude Opus 5.5 5f83b53a32 Type: keep superscript and subscript (FontBaseline) when a text layer is re-rendered (#5645)
CharStyle had no superscript/subscript attribute, so PSD import ignored FontBaseline and the first re-render (entering text editing) drew the run as plain text. Model it as CharStyle::baseline, read and write FontBaseline in EngineData, draw such runs at 58.3% of their size shifted by 33.3% of it (Photoshop defaults), and accept "baseline" in the character params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:13:41 +02:00
nrq6993-a11yandCursor c71f951b90 Fix View › Snap off still snapping via smart guides (storytold/photocraft#5782).
Smart-guide targets were built even when the master snap switch was off; gate them on snap_on like other targets.

Co-authored-by: Cursor <[email protected]>
2026-10-15 00:06:16 +03:00
David MannandClaude Opus 5.5 bb7c84f997 Fill layers: reject non-finite gradient angles and pattern angle/phase (#5752)
layer.newFillLayer.gradient and gradient.presets.apply cast `angle` to f32
unchecked, so 1e39 stored an infinite angle (NaN render, .pcraft save
refused) and 450 stayed 450. They now share gradient.fill.set's parsing:
non-finite is BadParams, the angle is normalised to (-180, 180].
Pattern placement (Pattern Fill layer, Edit > Fill, Paint Bucket) does the
same for its angle and rejects a phase outside +/-1e7 like pattern.fill.set.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 22:34:51 +02:00
Higgs32584andClaude Opus 5.5 b768970d4d Brush smoothing: the lagging brush keeps the pressure of where it is (#5713)
Smoother::push moved the brush part of the way to the pointer but gave
every point the pointer's newest pose (`..p`). On a fast stroke the
brush trails far behind, so when the pen lifted, the trailing brush took
the lift's near-zero pressure while still short of the end, and the
end catch-up drew a straight hairline from there to the pointer: a thick
stroke stepping down to a thin tail.

The brush's pressure, tilt, rotation (the short way round) and wheel now
move with its position, for exponential smoothing and the pulled string.
The end catch-up then ramps from the brush's pressure to the pen's last,
a taper instead of a step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:43:48 -04:00
David MannandClaude Opus 5.5 08abbf7d90 Layer masks: From Transparency and Mask All Objects convert the Background and match the document depth (#5679)
From Transparency and Mask All Objects attached a mask to the Background
without turning it into "Layer 0" first, unlike every other add-mask path.
They and Hide Selection also built their masks as 8-bit, so a 16/32-bit
layer's alpha was quantised to 256 levels by From Transparency. All three
now build the mask with mask_at_depth at the document depth; 8-bit
documents are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:37:46 +02:00
David MannandClaude Opus 5.5 afd735ba96 Image > Adjustments: locked smart objects refuse new smart filters (#5655)
adjust_as_smart_filter recorded image.adjustments.* (and Shadows/Highlights)
as a smart filter without checking locks, so a Lock All or Lock Pixels smart
object still took the filter. It now refuses with check_pixels_unlocked, the
same rule filter.* smart filters use. Regression test covers both locks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:57:05 +02:00
David MannandClaude Opus 5.5 f31fb26fe8 Video: Interpret Footage and New Video Layer from File reject an invalid frame rate (#5657)
Both commands stored the `fps` param unchecked: 0 or a negative rate was
kept, and 1e39 became an infinite rate the next .pcraft save refused. They
now use timeline_cmds::fps_param (finite, above 0, at most MAX_FPS), as
timeline.create and timeline.setProps do; a missing fps keeps the old
fallback. Regression test covers 0, -1, 1e39 and "x" for both commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:55:35 +02:00
David MannandClaude Opus 5.5 a0e5e6eb14 Count tool: deleting an earlier count group keeps the active group (#5653)
count.deleteGroup only clamped the active index to the new length, so
removing a group before the active one silently made the next group
active and later count.add calls landed there. Shift the index down
when the removed group precedes it; deleting the active last group
still selects the new last group.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:50:24 +02:00
David MannandClaude Opus 5.5 e29ec76d4c Smart Filters: deleting the last filter removes its filter mask (#5619)
Deleting or dragging away the only smart filter now clears the filter mask, as
Clear Smart Filters already did, so the next filter takes the new selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:57:40 +02:00
David MannandClaude Opus 5.5 baae6f506f Filters: Lens Correction, Adaptive Wide Angle and Camera Raw Filter respect locks on smart objects (#5621)
Root cause: run_filter in lens_cmds.rs added the smart filter and returned
for a smart object before it checked the pixel and full locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took the
filter and a history step.

Fix: check the locks before the smart-object branch, as other filters do.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:57:08 +02:00
Nigel Ihza Firdausy 92647dd6ff fix(svg): cast channel count for pixel budget arithmetic 2026-10-14 23:18:25 +07:00
Nigel Ihza Firdausy 6f46fcfc4a fix(svg): budget rasterised fallback layers across imports 2026-10-14 23:17:59 +07:00
jonathnwxh ff92cb3ffd Preserve editable clipboard contents when native reads fail 2026-10-14 22:57:13 +08:00
David MannandClaude Opus 5.5 32d965cf50 Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:29:17 +02:00
David MannandClaude Opus 5.5 aa26badf20 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:12:01 +02:00
David MannandClaude Opus 5.5 e795a09d31 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:10:52 +02:00
David MannandClaude Opus 5.5 afc09b4a05 Merge main into fix/4017-huge-new-document
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:09:12 +02:00
David MannandClaude Opus 5.5 0543f25682 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:07:24 +02:00
David MannandClaude Opus 5.5 afdc75faf9 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:54:24 +02:00
David MannandClaude Opus 5.5 4c160c5685 Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:52:46 +02:00
Juan Antonio RedondoandClaude Opus 5.5 78d06781a0 perf(retouch): row-parallel Blur/Sharpen dabs, 2.6-3.3x faster strokes (#5426)
The Blur and Sharpen tools ran each dab's separable Gaussian
(local_blur) and the mix back onto the working copy on one thread. A
large brush is a lot of work per dab: a 2500 px dab is about 10^9
multiply-adds, so strokes on big images stalled the app for seconds.

Both passes of local_blur and the per-dab mix now run their rows in
parallel with rayon, as blur.rs already does. Every row does exactly
the same arithmetic in the same order, so the output is bit-identical:
the layer pixels after the strokes below hash the same as on main.

paint.blur, 200-point stroke on a 6000x4000 noise layer, release,
10-core M-series:

  brush    commit (main -> this)   live stroke (main -> this)
  300 px   323 ms -> 124 ms        497 ms -> 314 ms
  1000 px  1.11 s -> 312 ms        1.70 s -> 996 ms
  2500 px  2.77 s -> 843 ms        4.26 s -> 2.44 s

The live stroke keeps a larger serial share (region copies and
mix_back per push); that is left for a follow-up.

Refs #5426

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:12:54 +02:00
jonathnwxh e2ed5cdcb7 Limit blend mode hover refresh to the visible canvas 2026-10-14 12:41:22 +08:00
Gert Pellin f805b12a17 PSD: a flat Bitmap file saves in Bitmap mode and opens as a Background again
#3560's test expected the flat Bitmap file to be saved with a layer section.
With #4814 Bitmap is written in its own mode, which has no layers in
Photoshop: the test now checks the Bitmap header, the missing layer section
and that the saved file reopens with its Background.
2026-10-13 21:47:41 +02:00
Gert Pellin bdbf1a2a71 Merge remote-tracking branch 'origin/main' into feat/4814-psd-palette-modes 2026-10-13 20:45:36 +02:00
UnemarsandClaude Opus 5.5 0c928a1bec Merge origin/main into content-aware-workspace
Conflicts:
- edit_menu_cmds.rs: main's #4133 (Sample All Layers off unless the active layer has no pixels
  in the window) now decides `sampleAllLayers` inside the workspace's fill plan; the job keeps
  the plan-based fill, the spec keeps the workspace's parameters.
- lib.rs: both module lines (caf_ui, cam_preview).
- hu, tr, vi catalogs: both sides' rows. Dropped de's High/Low (main now has them) and hu's
  older "Vanishing Point" row main reworded. Added the 15 workspace strings to sr.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 20:22:22 +02:00
David MannandClaude Opus 5.5 f28d3978ea Sort the engine module list after the merge (cargo fmt)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 20:20:02 +02:00
David MannandClaude Opus 5.5 577c4eea67 Merge origin/main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 20:10:40 +02:00
David MannandClaude Opus 5.5 9c608e63b7 Merge origin/main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 19:55:08 +02:00
David MannandClaude Opus 5.5 3120afdbb2 Merge origin/main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 19:52:41 +02:00
David MannandClaude Opus 5.5 d2816df448 Merge origin/main into docs/scorecard-stale-rows-2
None of the five rows was flipped on main; every test the notes cite
exists on main. Main made TYPE-218-1 (type on a path) partial, so the
Type recount in gaps.md G8 and the target-app-parity Type row now say
5 done, 3 partial, 1 missing and name it partial. Header lines of
gaps.md and ui-parity.md keep main's entries plus this PR's.
docs/scorecard.md regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:44:44 +02:00
David MannandClaude Opus 5.5 8b9b3a048b Merge origin/main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:40:32 +02:00
David MannandClaude Opus 5.5 7c5505baa2 Merge origin/main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:40:28 +02:00
David MannandClaude Opus 5.5 ebd3c12270 Large documents: a gigapixel New Document draws without freezing (#4017)
The reduced CPU canvas composites one-colour tiles once per colour instead
of every document pixel, and GPU canvas textures the device refuses fall
back to the CPU canvas instead of disabling the GPU for the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:21:45 +02:00
David MannandClaude Opus 5.5 afb7de2d3a Performance: brush dab on a CMYK document within budget (#5108)
A brush dab on an A4 300 ppi CMYK page spent ~5 ms per touched tile in the
GPU canvas's tile upload: every pixel of the 256x256 tile went through the
CMYK -> sRGB transform on one thread. CMYK and Lab tiles now convert in
16-row bands in parallel, and a run of identical pixels (the transparent or
flat area around a dab) is converted once. Texels are bit-identical to the
per-pixel conversion.

The band workers enter the document's CMYK profile scope; before, the rayon
workers converting tiles did not, so a CMYK document tagged with its own
profile uploaded tiles through the built-in coated CMYK instead.

P33 (Windows 11, 32 threads, RTX 5070 Ti, 15 reps): p95 13.7-16.8 ms -> 2.6-3.3 ms.
enforce stays false: the committed baseline machine measures every row
over budget, so it is left for the next baseline.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:10:21 +02:00
David MannandClaude Opus 5.5 8eef90bf7e Tests: generated large-file fixtures and a P20 number (#5107)
photocraft_psd::fixtures (feature testgen) generates the FILE-216-2
fixtures at test time: a 20 MP layered PSD and a 150-layer A4 layout
(groups, masks, hidden layers, drop shadows, known merged colours), a
2.02 GB PSD just under Photoshop's cap and the 4-5 GB PSBs, streamed row
by row. io tests/large_files.rs opens, checks, saves and re-opens each
(the 2 GB PSD and 4.38 GB PSB are opt-in). The ui-egui large_file_bench
times opening the big two, wired to P20 and a new P60.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:58:38 +02:00
David MannandClaude Opus 5.5 511b55bec3 History Brush source: Serbian labels
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 13:10:26 +02:00
David MannandClaude Opus 5.5 716e398ede Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:47:44 +02:00
David MannandClaude Opus 5.5 09af99c6f3 History panel menu: Serbian labels
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:41:44 +02:00
David MannandClaude Opus 5.5 c951480e99 Merge origin/main into feat/tool-213-7-live-retouch
Background Eraser: main now samples and matches against the pre-stroke pixels (#4552); the
shared dab effect takes the pre-stroke surface so the live preview, whose working areas are
per dab, reads the same pixels as the commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:33:25 +02:00
David MannandClaude Opus 5.5 03f929bffb Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:08:08 +02:00
David MannandClaude Opus 5.5 402d005e05 Merge origin/main into docs/scorecard-stale-rows-2
Conflicts in docs/gaps.md, docs/target-app-parity.md and docs/ui-parity.md:
kept main's status lines and rows and this branch's Type, selection-modifier
and Alt-wheel evidence. docs/scorecard.md regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:04:36 +02:00
David MannandClaude Opus 5.5 71ea169a84 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:04:26 +02:00
Gert Pellin 5f59f0671a Layered TIFF: Indexed, Bitmap and Duotone layers are modelled in their pixel format
The layered TIFF export builds its layer data through the PSD exporter,
which now writes these modes in their own PSD mode (palette, 1 bit,
duotone). TIFF has no such layout and interleaves the merged image in
the pixel format, so the PSD model for it is made in that format (RGB or
Grayscale), as before.

Refs #4814
2026-10-13 11:51:34 +02:00
Gert Pellin 2a10524d53 PSD: save Indexed Color, Bitmap and Duotone documents in their own mode
Indexed Color (mode 2): the flattened document as palette indices, the
Color Table as Color Mode Data (256 reds, greens, blues), its length in
resource 1046 and the transparent entry in 1047; the importer now reads
both back. Bitmap (mode 0): 1 bit per pixel, 1 = black. Neither mode has
layers in Photoshop, so both are written flat, like Multichannel; the
layerless file building is shared with multichannel_map.

A Duotone keeps the ink data it was imported with (undocumented, kept
verbatim in Duotone::psd_raw) over its grey plate. Duotones whose inks
were set in PhotoCraft have no such data and stay Grayscale with the
warning, as before.

Fixes #4814
2026-10-13 09:00:20 +02:00
David Mann 601d92ab8b Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch 2026-10-13 01:45:11 +02:00
David MannandClaude Opus 5.5 d553a1b29f Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:45:06 +02:00
David Mann ca65995579 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2 2026-10-13 01:43:37 +02:00
David MannandClaude Opus 5.5 dbb2062118 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:43:25 +02:00
David MannandClaude Opus 5.5 9b70086a2d Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:43:25 +02:00
David MannandClaude Opus 5.5 fdef9180a7 History panel menu: Hungarian and Turkish labels
Hungarian and Turkish now claim complete menus, so the History panel
menu's New Snapshot… and History Options… need entries there too.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:36:49 +02:00
David MannandClaude Opus 5.5 2fff5845b9 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:23:39 +02:00
David MannandClaude Opus 5.5 507588aab3 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:21:56 +02:00
David MannandClaude Opus 5.5 ed26278c8b Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:18:48 +02:00
David Mann 001d16cd9f Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch 2026-10-13 01:15:02 +02:00
David MannandClaude Opus 5.5 9e22da62f6 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:06:25 +02:00
David Mann 132935eb2e Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/target-app-parity.md
2026-10-13 01:01:57 +02:00
David Mann cbe11954c6 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	crates/ui-egui/src/dock.rs
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/panel_docking.rs
2026-10-13 00:59:37 +02:00
David Mann 4c0e1f3770 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# 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/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-13 00:52:27 +02:00
David Mann 018213b8ea Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-13 00:29:04 +02:00
David MannandClaude Opus 5.5 7c501ed90d Merge fixups: translate the History Brush source strings into Hungarian and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 00:16:07 +02:00
David Mann 23cf5ec69a Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# 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/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-13 00:07:53 +02:00
David MannandClaude Opus 5.5 8c7a2de4a7 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 00:04:59 +02:00
David Mann 2ed5fd38c2 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	crates/ui-egui/src/i18n/vi.tsv
2026-10-13 00:01:56 +02:00
UnemarsandClaude Opus 5.5 52399524fe i18n(it): drop the workspace's rows main's Italian catalog now has (High, Low, Duplicate Layer, Contract)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 23:00:48 +02:00
UnemarsandClaude Opus 5.5 100cedbdb7 i18n(hu): the Content-Aware Fill workspace in the Hungarian catalog from main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:23:26 +02:00
UnemarsandClaude Opus 5.5 ea3c9506f5 i18n(tr): the Content-Aware Fill workspace in the Turkish catalog from main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:11:34 +02:00
UnemarsandClaude Opus 5.5 8cd6f16b88 Merge origin/main d43db3e1 into content-aware-workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:11:34 +02:00
David MannandClaude Opus 5.5 684f211a72 Merge fixups: translate the History Brush source strings
i18n::tests::every_tl_literal_is_translated needs every tl! string in the complete catalogs:
the options bar hint and the History panel source column tooltip get rows in all 17.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 14:10:23 +02:00
David Mann 27f001c3fa Merge remote-tracking branch 'origin/main' into fix/history-brush-source 2026-10-12 14:10:23 +02:00
David MannandClaude Opus 5.5 5241773022 Merge fixups: Vietnamese History panel menu labels
main added a complete Vietnamese catalog; the panel menu's new labels need rows there too.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 13:27:24 +02:00
David Mann 4a0617ce83 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu 2026-10-12 13:27:24 +02:00
AlastorIRL 5cd134f353 fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback 2026-10-12 08:16:25 -03:00
David Mann 9c2d6ac892 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-12 12:27:32 +02:00
David Mann 89f4bb51dd Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch
# Conflicts:
#	docs/brush-parity.md
#	docs/gaps.md
#	docs/scorecard.md
2026-10-12 12:07:03 +02:00
UnemarsandClaude Opus 5.5 1705492c02 Merge main into content-aware-workspace: #3395's cancellable fill, bounded workspace params
- The fill area and sampling area (`content_aware_plan_with`) are worked out
  inside the background job, cancellable row by row as #3395 made the rest of
  the fill: the window and selection are read in parallel bands, Auto sampling
  is `auto_sampling_with`. The params are still checked before the job starts.
  The workspace's preview builds its plan with its own cancel flag.
- The apply step is #3395's: only the fill area's bounds keep the original
  pixels and coverage. `CafPlan::layer_px` is kept only when Sample All Layers
  samples something else than the layer.
- `fillEdits` and `samplingStrokes` take at most 1000 entries and 200 000
  points in all (MAX_CAF_EDITS, MAX_CAF_POINTS).
- `paint_strokes` leaves a mask that does not cover its area alone, writes
  through `get_mut`, and visits each segment's own bounds instead of the whole
  stroke's for every segment (the same pixels). The workspace's
  `sampled_at`, `in_hole` and outline read their buffers with `get`.
- Content-Aware Scale's transform box (#3096) and the Vanishing Point
  workspace keep their menu entries next to the Content-Aware Fill workspace.
- Vietnamese labels for the workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:50:41 +02:00
Brandon ThomasandClaude Opus 5.5 c824a61ea1 Merge main; recount Type evidence and regenerate the scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:21:38 -04:00
David Mann 46e833e9c6 Merge origin/main
# Conflicts:
#	docs/gaps.md
2026-10-12 00:14:07 +02:00
David Mann d701538974 Merge origin/main
# Conflicts:
#	docs/gaps.md
2026-10-11 23:51:37 +02:00
David MannandClaude Opus 5.5 086a82d547 Pattern Stamp and Background Eraser draw live; Spot Healing shows its heal area (TOOL-213-7, #3754)
Pattern Stamp (aligned through LiveRetouch, Impressionist dab by dab) and
Background Eraser now paint during the drag; the release commits exactly the
pixels the live stroke showed (per-pixel tests at 8 and 16 bits). Spot
Healing keeps healing on release and shows a dark overlay while dragging.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 23:50:35 +02:00
David MannandClaude Opus 5.5 1c8199d417 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 20:52:18 +02:00
David Mann cf79e20685 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu
# Conflicts:
#	crates/ui-egui/src/i18n/pt-br.tsv
2026-10-11 20:50:35 +02:00
David MannandClaude Opus 5.5 2b738e0c7c History: panel menu, Step Forward/Backward and Clear History (#2966)
The History panel's menu (☰) now has Photoshop's items: Step Forward, Step
Backward, New Snapshot… (greyed, snapshots aren't built yet), Delete, Clear
History, New Document, History Options… (greyed, built in parallel) and Close,
above the dock's own group items. Each runs an engine command.

- history.stepForward / history.stepBackward: one state along the list
  (Err when there is none).
- history.clear: removes every state but the current one without changing
  the image. It is undoable: History keeps the cleared states until the next
  step, and Edit > Undo puts them back. Fade is dropped and can no longer
  underflow on an empty history.
- history.deleteState is enabled only when there is an earlier state.
- Translations for the new labels in all complete-menu languages.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 17:56:26 +02:00
David MannandClaude Opus 5.5 b5e3e80a3b History: set the History Brush source from the panel (history.setSource) (#2966)
The History panel gets Photoshop's source column at the left of every state:
the History Brush icon marks the source, and a click there makes that state
the source through the new `history.setSource {state}` command (not a step,
and the row is not stepped to). `paint.historyBrush`, the interactive History
Brush and Fill > History use the source when no `state` is passed; by
default it is the oldest state held, as before.

The source is kept by state, not index: photocraft-ops gives every history
state a stable id, so the source stays on its state while older states fall
off. As in Photoshop (checked by script), the source state itself does not
fall off the history-state limit or the memory budget, and once it is
deleted or discarded by a new step after stepping back there is no source:
the History Brush then refuses to paint without an explicit `state` until a
new source is set. Rows count the greyed redo states too, so the common
"filter, step back, paint the filtered state in" workflow works.

`document.inspect` / `ui_inspect` report `historySource` {state, label}
(null when its state was deleted). Snapshots are not implemented yet:
`snapshot` returns an "unsupported" error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 17:37:42 +02:00
David MannandClaude Opus 5.5 46f046ca4e scorecard: five rows catch up with work already on main
AGENTS.md sends contributors to the scorecard's missing and partial rows
when picking work; these five described code that has since landed.

- TYPE-218-7 missing -> done: Warp Text opens with the layer's warp
  (filter_dialog.rs warp_defaults) and previews live; covered by
  warp_text_dialog_tests.rs and
  warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy.
- TOOL-214-13 partial -> done: Properties shows Density and Feather for
  both pixel and vector masks (mask_props_ui.rs, mask_props_ui_tests.rs).
- BUG-207-5 missing -> done: all twelve retouch tools are tested against
  mask, alpha channel, Channels-panel and Quick Mask targets; Sample All
  Layers and the smudge fringe have their own tests (retouch_cmds/tests.rs).
  Pencil and Mixer Brush targets are still untested, noted in the row.
- UI-217-18 missing -> partial: Alt+wheel zooms around the pointer at
  x1.1 a notch, measured on Photoshop in #1718, not the ~5 % the row asks
  for; the status bar zoom controls fit (#2823); Navigator not checked.
- BUG-208-4 stays partial with a corrected note: marquee add/subtract,
  Lasso add/subtract/intersect and Polygonal Lasso are tested from
  modifiers held at the press; a marquee Shift+Alt intersect test is
  still missing.

docs/gaps.md (G8), docs/target-app-parity.md (Type row) and
docs/ui-parity.md updated to match, with revision-history rows.
docs/scorecard.md regenerated: Tools 26/15/2, Workspace and UI 8/9/6,
Type 2/4/3, Open bugs 30/2/0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 16:27:16 +02:00
UnemarsandClaude Opus 5.5 994c313706 Merge origin/main into content-aware-workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 01:35:58 +02:00
UnemarsandClaude Opus 5.5 2507f07ec2 Format: one rustfmt change main needs
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 10:11:15 +02:00
Unemars 7a5795bfcb Merge remote-tracking branch 'origin/main' into content-aware-workspace
# 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-10 10:06:08 +02:00
UnemarsandClaude Opus 5.5 4d7db6dabe Content-Aware Fill workspace, as Photoshop's
Edit › Content-Aware Fill… opens Photoshop's workspace (measured on 25.4)
instead of the generic dialog: a full-window dialog like Liquify's.

- Tools: Sampling Brush (B), Lasso (L), Hand (H), Zoom (Z), each with its
  options bar.
- Document view: the sampling area tinted green at 50 % and the fill area's
  marching ants.
- Preview panel: the fill computed in the background after each change,
  opening framed on the sampling window.
- Content-Aware Fill panel: Sampling Area Overlay (Show, Opacity, Color,
  Indicates), Sampling Area Options (Auto, Rectangular, Custom, Sample All
  Layers), Fill Settings, Output Settings, and the reset buttons.
- OK, Apply, Cancel.

As Photoshop's:
- The Sampling Brush subtracts in Auto and Rectangular and adds in Custom,
  which starts empty; Alt reverses. Its first size is a tenth of the
  window's side. Painting keeps the option, and added strokes may lie past
  the window.
- The lasso edits the fill area (New, Add, Subtract, Intersect, Expand,
  Contract); the window and the Auto area follow.
- Apply fills and stays open; Cancel keeps what was applied. Ctrl+Z undoes
  a stroke or an edit. Output opens on New Layer; settings are remembered.

The workspace holds plain data. `edit.contentAwareFill` takes it as
`samplingStrokes`, `fillEdits`, `sampleAllLayers` and `layer`, and both
the preview and OK run one plan (`content_aware_plan`), so the preview is
what gets written. New strings are translated in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 10:05:40 +02:00
UnemarsandClaude Opus 5.5 2e1f3b0a41 Content-Aware tools: Photoshop's behaviour, measured
Photoshop 25.4 was measured as a black box (synthetic and public-domain
images, results read back exactly) and the content-aware family follows it:

- Fill (inpaint): coarse to fine with the coarser level's matches scaled up
  and voted with the finer level's own pixels, the coarsest level filled
  from the hole's edge inwards, a shallower pyramid. The fill is pieces of
  the image copied pixel for pixel with narrow seams, as Photoshop's;
  before, it was visibly blurred. Pixels have roles (hole, context,
  source, void), so excluded areas stay context instead of being filled.
- Colour adaptation: Image Melding's per-patch gain and bias, limited per
  level, instead of a low-frequency membrane pull.
- Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50))
  centred on the selection, slid inside the canvas; Edit › Fill,
  Rectangular, Auto, Delete and Fill and Spot Healing use it.
- Auto sampling area: what looks like the selection's surroundings (local
  colour and contrast, one min cut), fitted to Photoshop's overlay.
- Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers).
- Content-Aware Move: Structure 3..7 alike, a band about a patch wide;
  Color as a gain/bias limited to about 0.04 per step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 02:25:24 +02:00
554 changed files with 46631 additions and 3910 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -361,7 +361,7 @@ jobs:
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
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+4 -4
View File
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
+4 -2
View File
@@ -27,7 +27,7 @@ next to them.
| `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) |
| `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`, `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`, `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/{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/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -38,11 +38,12 @@ next to them.
| `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/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 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -109,6 +110,7 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `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) |
Generated
+97 -49
View File
@@ -849,7 +849,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"winx",
]
@@ -1234,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
"roxmltree",
"roxmltree 0.20.0",
"tempfile",
"winprint",
"winsafe",
@@ -1896,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1934,6 +1934,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exacl"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
dependencies = [
"bitflags 2.13.2",
"log",
"scopeguard",
"uuid",
]
[[package]]
name = "exr"
version = "1.74.2"
@@ -2104,7 +2116,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
"roxmltree 0.20.0",
]
[[package]]
@@ -2129,9 +2141,17 @@ dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts",
"roxmltree 0.21.1",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
@@ -3033,7 +3053,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3695,7 +3715,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4441,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4618,7 +4638,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"arboard",
"async-io",
@@ -4663,7 +4683,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4673,7 +4693,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4686,7 +4706,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4699,6 +4719,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"rmcp",
"rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4708,7 +4729,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4722,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"moxcms",
"rayon",
@@ -4730,7 +4751,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"exr",
"flate2",
@@ -4751,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4759,7 +4780,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4774,7 +4795,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4785,7 +4806,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"craft-print",
@@ -4811,20 +4832,23 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"rayon",
"roxmltree",
"roxmltree 0.20.0",
"same-file",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
"winsafe",
]
[[package]]
name = "photocraft-format"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"crc32fast",
"exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4838,21 +4862,23 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"winsafe",
]
[[package]]
name = "photocraft-geom"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"half",
"log",
"photocraft-cms",
"photocraft-color",
"photocraft-compose",
"photocraft-doc",
@@ -4866,14 +4892,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"craft-docpath",
@@ -4896,25 +4922,25 @@ dependencies = [
"photocraft-vector",
"proptest",
"resvg",
"roxmltree",
"roxmltree 0.20.0",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.9.0"
version = "0.12.0"
[[package]]
name = "photocraft-ops"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4925,7 +4951,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4940,7 +4966,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4951,17 +4977,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
"rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4976,7 +5003,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4988,7 +5015,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4999,7 +5026,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"parley",
@@ -5008,12 +5035,13 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5055,7 +5083,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5066,7 +5094,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5088,7 +5116,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"js-sys",
@@ -5481,7 +5509,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5942,6 +5970,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6000,7 +6037,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6067,7 +6104,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6705,7 +6742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6932,10 +6969,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7335,7 +7372,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7442,7 +7479,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
"roxmltree",
"roxmltree 0.20.0",
"rustybuzz",
"simplecss",
"siphasher",
@@ -8095,7 +8132,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8675,7 +8712,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -8688,6 +8725,17 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.3"
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+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"; \
+4 -1
View File
@@ -20,6 +20,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -199,7 +202,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
+8
View File
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return Ok(());
}
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
// the print command checks for it first and reports an error instead.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
+75 -9
View File
@@ -15,19 +15,21 @@ 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>]
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]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--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.
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>]
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]
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>]
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]
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
@@ -69,18 +71,23 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--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"],
bare: &["--tiff-layers"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -219,6 +226,11 @@ 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("--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"))?;
}
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
@@ -444,11 +456,32 @@ fn is_input(p: &Path) -> bool {
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
set.iter_mut().for_each(strip_shell_steps);
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
@@ -751,4 +784,37 @@ mod batch_action_set_tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let (code, stdout, stderr) =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+48 -5
View File
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
// writes, after the decode warning.
let cases = [
("whole.jpg", &jpeg[..], None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
("whole.jpg", &jpeg[..], None, None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
];
for (name, bytes, warning) in cases {
for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
expected.join(
"
"
),
"{name}"
);
}
std::fs::remove_dir_all(d).unwrap();
}
@@ -494,6 +504,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
}
}
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (args, fourcc, mips) in [
(&[][..], [0; 4], 1),
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
(&["--dds-mipmaps"][..], [0; 4], 4),
] {
let out = d.join("a.dds");
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
let bytes = std::fs::read(&out).unwrap();
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(&out).arg(&back));
assert!(back.exists(), "{args:?}");
}
for bad in ["bc7", "dxt2", ""] {
let out = d.join("bad.dds");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
+1 -1
View File
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+117 -6
View File
@@ -8,7 +8,10 @@
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
//! In one that can't, this module inflates them itself, a little slower.
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
//! taken as it is, by its first bytes.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option<Vec<String>> {
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// What the joined parts hold, told by their first bytes.
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Payload {
/// The gzip packaging made, still to be inflated.
Gzip,
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
/// already undid.
Wasm,
}
/// Enough leading bytes to tell the payloads apart.
#[cfg(any(target_arch = "wasm32", test))]
const HEAD: usize = 4;
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
/// `None` when it is neither, such as a web page or nothing at all.
#[cfg(any(target_arch = "wasm32", test))]
fn payload(head: &[u8]) -> Option<Payload> {
if head.starts_with(&[0x1f, 0x8b]) {
Some(Payload::Gzip)
} else if head.starts_with(b"\0asm") {
Some(Payload::Wasm)
} else {
None
}
}
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
@@ -69,11 +100,11 @@ mod boot {
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
};
use super::Inflater;
use super::{HEAD, Inflater, Payload};
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
@@ -172,11 +203,71 @@ mod boot {
Ok(compressed.pipe_through(&pair))
}
/// The next chunk `reader` gives, or `None` at the end of the stream.
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
let result = JsFuture::from(reader.read()).await?;
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
return Ok(None);
}
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
}
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
let _ = reader.cancel_with_reason(&e);
return Err(e);
}
Ok(())
}
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
for chunk in &held {
write(chunk, reader, writer).await?;
}
while let Some(chunk) = read(reader).await? {
write(&chunk, reader, writer).await?;
}
JsFuture::from(writer.close()).await?;
Ok(())
}
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
/// browser has already done that.
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
while head.len() < HEAD {
let Some(chunk) = read(&reader).await? else { break };
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
head.extend(chunk.subarray(0, wanted).to_vec());
held.push(chunk);
}
let Some(payload) = super::payload(&head) else {
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
// Stops the rest of the download, which nothing will read.
let _ = reader.cancel_with_reason(&e);
return Err(e);
};
let rest = TransformStream::new()?;
let writer = rest.writable().get_writer()?;
spawn_local(async move {
if let Err(e) = pass_on(held, &reader, &writer).await {
let _ = writer.abort_with_reason(&e);
}
});
match payload {
Payload::Gzip => inflated(&rest.readable()),
Payload::Wasm => Ok(rest.readable()),
}
}
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
@@ -187,6 +278,10 @@ mod boot {
failed.replace(Some(e));
}
});
let wasm = match unpacked(&joined.readable()).await {
Ok(wasm) => wasm,
Err(e) => return Err(cause.take().unwrap_or(e)),
};
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
@@ -258,7 +353,7 @@ fn main() {
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
@@ -317,6 +412,22 @@ mod tests {
assert!(run(b"").is_err(), "nothing at all");
}
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
#[test]
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
wasm.extend(sample());
let packed = gzip(&wasm);
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
assert_eq!(payload(b""), None, "nothing at all");
}
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
+5 -3
View File
@@ -19,9 +19,9 @@ 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.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"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",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -427,6 +427,8 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
// A download may be cancelled and never reports back: it doesn't save the document.
write_unconfirmed: true,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
+8
View File
@@ -11,6 +11,14 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
// the print command checks for it first and reports an error instead.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "PhotoCraft")
+60 -13
View File
@@ -10,8 +10,8 @@
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
//!
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
//! roaming profile ([`recovery_dir`]).
//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced
//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -77,18 +77,35 @@ pub fn recovery_dir() -> Option<PathBuf> {
})
}
/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
/// few minutes, so on Windows the platform convention puts them in the machine-local
/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
/// a crashed document is recovered. The override and portable folders keep everything in one
/// place, and other platforms already keep these folders local.
/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few
/// minutes, so with the platform convention they stay out of what is synced with the settings:
/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
/// system.
///
/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
/// document is recovered. The override and portable folders keep everything in one place.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if cfg!(windows)
&& data.mode == Mode::Platform
&& let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
{
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
if data.mode == Mode::Platform {
if cfg!(windows) {
if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
}
} else if !cfg!(target_os = "macos") {
let state = env("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|s| s.is_absolute())
.or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
if let Some(state) = state {
return Some(state.join("photocraft").join(RECOVERY));
}
}
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
@@ -623,6 +640,34 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
/// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
/// and macOS don't read it, and an override or portable folder keeps them inside itself.
#[test]
fn autosaves_go_to_the_xdg_state_folder() {
let root = temp("recovery-xdg");
let settings = root.join("home").join(".config").join("photocraft");
let home = root.join("home");
let state = root.join("state");
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
let default = home.join(".local").join("state").join("photocraft").join("Recovery");
assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
let relative = PathBuf::from("relative/state");
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
let empty = PathBuf::new();
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
// No home at all: beside the settings, as before.
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
for mode in [Mode::Override, Mode::Portable] {
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
fn autosave(dir: &Path, key: &str) {
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
@@ -784,6 +829,7 @@ mod tests {
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
#[test]
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
let _spawns = crate::no_child_spawns();
let root = temp("recovery-timeout");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
@@ -866,6 +912,7 @@ mod tests {
/// once the lock is released, the move happens.
#[test]
fn a_move_waits_for_another_process_holding_the_lock() {
let _spawns = crate::no_child_spawns();
let root = temp("recovery-lock");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
+115 -14
View File
@@ -70,25 +70,61 @@ pub fn start() {
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
///
/// Asks the portal object to introspect itself rather than reading the interface's `version`
/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
/// didn't count as absent (#5739). The interface list answers the question directly.
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
let reply = conn.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.DBus.Introspectable"),
"Introspect",
&(),
);
match reply {
Ok(message) => match message.body().deserialize::<String>() {
Ok(xml) => Some(interface_listed(&xml)),
Err(_) => None,
},
Err(e) => classify_error(&e),
}
}
/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
/// `FileChooser2`).
#[cfg(target_os = "linux")]
fn interface_listed(xml: &str) -> bool {
["\"", "'"].iter().any(|quote| xml.contains(&format!("<interface name={quote}org.freedesktop.portal.FileChooser{quote}")))
}
/// What a failed call says about the interface: `Some(false)` when the D-Bus error means the
/// service, object or interface isn't there, `None` for anything else (a timeout, a dropped
/// connection, an unknown error), which counts as present.
#[cfg(target_os = "linux")]
fn classify_error(e: &zbus::Error) -> Option<bool> {
use zbus::fdo::Error as Fdo;
// Names a bus daemon or portal answers with when the thing asked about is absent.
const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
match e {
zbus::Error::FDO(fdo) => matches!(
**fdo,
Fdo::ServiceUnknown(_)
| Fdo::NameHasNoOwner(_)
| Fdo::UnknownObject(_)
| Fdo::UnknownInterface(_)
| Fdo::UnknownMethod(_)
| Fdo::UnknownProperty(_)
| Fdo::InvalidArgs(_)
)
.then_some(false),
zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
_ => None,
}
}
@@ -126,4 +162,69 @@ mod tests {
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
mod portal {
use super::super::*;
const WITH_FILE_CHOOSER: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/introspect.dtd">
<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"><property name="version" type="u" access="read"/></interface>
<interface name="org.freedesktop.portal.FileChooser">
<method name="OpenFile"/>
</interface>
</node>"#;
const WITHOUT_FILE_CHOOSER: &str = r#"<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"/>
</node>"#;
fn method_error(name: &'static str) -> zbus::Error {
let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
}
#[test]
fn the_interface_list_says_whether_a_file_chooser_is_offered() {
assert!(interface_listed(WITH_FILE_CHOOSER));
assert!(interface_listed("<interface name='org.freedesktop.portal.FileChooser'>"), "single quotes");
assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
assert!(!interface_listed(r#"<interface name="org.freedesktop.portal.FileChooser2">"#), "longer name");
assert!(!interface_listed(""));
}
#[test]
fn dbus_errors_for_a_missing_interface_mean_absent() {
// zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
// `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
// when no backend offers the interface (#5739).
for e in [
zbus::fdo::Error::InvalidArgs("No such interface".into()),
zbus::fdo::Error::UnknownInterface("x".into()),
zbus::fdo::Error::ServiceUnknown("x".into()),
zbus::fdo::Error::NameHasNoOwner("x".into()),
zbus::fdo::Error::UnknownObject("x".into()),
] {
let desc = format!("{e:?}");
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
}
}
#[test]
fn method_error_names_still_count_as_absent() {
for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
}
assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
}
#[test]
fn inconclusive_errors_are_not_absent() {
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
}
}
}
+214 -16
View File
@@ -12,8 +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. `WGPU_BACKEND` still
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
//! 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.
//!
//! "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
@@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
})
}
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
/// choice.
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
}
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::DX12,
flags: desc.flags,
backend_options: desc.backend_options.clone(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
}
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
@@ -228,6 +252,8 @@ pub struct Candidate {
pub backend: wgpu::Backend,
/// Can present to the window.
pub surface_ok: bool,
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
pub wanted: bool,
}
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
@@ -259,19 +285,59 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
(fallback, ty, be)
}
/// The adapter to use among `adapters` (`None` when there is none).
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
/// win over the rest when any of them can present; otherwise the ranking decides (see
/// [`wanted_missed`]).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
let usable = |a: &&Candidate| a.surface_ok;
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
}
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
}
/// The user asked for an adapter and none of `adapters` that can present is it.
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
}
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
/// single Metal path, and neither is tested with a requested adapter.
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
/// which wants a software adapter whatever the preference says.
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
}
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
/// launch may still be forwarded to the running window.
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
}
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
/// `performance.gpuAdapter`; `None` is automatic.
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).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
/// 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
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -285,7 +351,7 @@ 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).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
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) {
create.instance_descriptor.backends = b;
@@ -298,7 +364,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
if let Some(b) = without_vulkan {
create.instance_descriptor.backends = b;
}
let vulkan_left_out = without_vulkan.is_some();
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
if select {
let backend = plan.backend;
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
@@ -311,15 +383,20 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
device_type: i.device_type,
backend: i.backend,
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
}
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
}
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*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) {
if backend == GpuBackend::Auto && intel_dx12_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());
}
@@ -443,6 +520,13 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
/// opens; empty (automatic) when the file or value is missing.
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
@@ -604,7 +688,82 @@ mod tests {
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
Candidate { vendor, device_type, backend, surface_ok: true }
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
}
#[test]
fn a_requested_adapter_beats_the_automatic_ranking() {
use wgpu::{Backend as B, DeviceType as T};
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
a[2].wanted = true;
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
a[2].wanted = false;
a[0].wanted = true;
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
// A wanted adapter that can't present is skipped, not an error.
a[0].surface_ok = false;
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
assert!(wanted_missed(&a, Some("iris")));
assert!(!wanted_missed(&a, None));
a[0].surface_ok = true;
assert!(!wanted_missed(&a, Some("iris")));
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
gl.iter_mut().for_each(|c| c.wanted = true);
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
}
#[test]
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
let w = || Some("nvidia".to_string());
assert_eq!(applicable_adapter(w(), Auto, true), w());
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
assert_eq!(applicable_adapter(w(), Cpu, true), None);
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
assert_eq!(applicable_adapter(None, Auto, true), None);
}
#[test]
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
assert!(!on(Some(" "), RenderingMode::Auto, true));
assert!(!on(None, RenderingMode::Auto, true));
}
#[test]
fn adapter_names_match_loosely_and_the_environment_wins() {
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
assert!(!adapter_matches("anything", ""));
assert!(!adapter_matches("anything", " "));
assert_eq!(wanted_adapter(None, ""), None);
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
}
#[test]
fn gpu_adapter_is_read_before_the_window_opens() {
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_gpu_adapter(Some(&file))
};
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
assert_eq!(read(r#"{"performance":{}}"#), "");
assert_eq!(read("not json"), "");
assert_eq!(read_gpu_adapter(None), "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
@@ -613,13 +772,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));
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
// 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));
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
// 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));
@@ -630,6 +789,45 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #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.
#[test]
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
use wgpu::{Backend as B, DeviceType as T};
const NVIDIA: u32 = 0x10de;
const AMD: u32 = 0x1002;
const WARP: u32 = 0x1414;
let auto = plan(Auto, None, None, false, Os::Windows);
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
// No Intel adapter: egui's default backends, Vulkan included.
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
// don't list the DX12 adapters at all.
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
for (p, os) in [
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
(plan(Auto, None, None, false, Os::Other), Os::Other),
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
] {
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
}
// Without Vulkan adapters to compare with, System Info still names the Intel default.
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));
}
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
@@ -894,7 +1092,7 @@ mod tests {
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
@@ -918,13 +1116,13 @@ mod tests {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
+3
View File
@@ -420,6 +420,7 @@ mod tests {
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("rotate");
for run in 1..=4 {
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
@@ -438,6 +439,7 @@ mod tests {
/// `.2.log`, and a fourth deleted it.
#[test]
fn a_second_start_leaves_the_running_instance_log_alone() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("second");
let mut first = rotate(&dir).expect("first start");
assert_eq!(first.path, dir.join(LOG_FILE));
@@ -461,6 +463,7 @@ mod tests {
#[test]
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("prune");
let first = rotate(&dir).expect("first start");
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
+294 -105
View File
@@ -71,8 +71,7 @@ mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod window_frame;
@@ -205,6 +204,131 @@ fn print_stdout(text: &str) {
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
/// Everything the desktop launcher takes from the command line, on top of the environment
/// defaults handed in to `parse_launch_args`.
#[derive(Debug, Default, PartialEq, Eq)]
struct LaunchArgs {
control_port: Option<u16>,
control_token: Option<String>,
control_token_file: Option<std::path::PathBuf>,
automation_read_root: Option<std::path::PathBuf>,
automation_write_root: Option<std::path::PathBuf>,
files: Vec<String>,
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
unreadable_paths: Vec<String>,
safe_gpu: bool,
new_instance: bool,
/// Options that apply only to the process they're given to; a launch with any keeps its own.
launch_options: Vec<&'static str>,
present_mode_arg: Option<String>,
frame_latency_arg: Option<String>,
stroke_trace_arg: Option<String>,
in_window_menus: bool,
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
help: bool,
/// `--version`: print the version and exit 0; parsing stops there.
version: bool,
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
errors: Vec<String>,
}
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
let mut args = args;
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => out.files.push(s),
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
out.help = true;
break;
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => out.control_port = Some(port),
Err(error) => out.errors.push(error),
},
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => out.control_token = Some(token),
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
},
Some("--control-token-file") => match args.next() {
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
},
Some("--automation-read-root") => {
out.launch_options.push("--automation-read-root");
match args.next() {
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
}
}
Some("--automation-write-root") => {
out.launch_options.push("--automation-write-root");
match args.next() {
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
}
}
Some("--safe-gpu") => {
out.launch_options.push("--safe-gpu");
out.safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
out.launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => out.present_mode_arg = Some(v),
"--frame-latency" => out.frame_latency_arg = Some(v),
_ => out.stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
out.launch_options.push("--in-window-menus");
out.in_window_menus = true;
}
Some("--new-instance") => out.new_instance = true,
Some("--version") => {
out.version = true;
break;
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => out.files.push(s.to_owned()),
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
out
}
#[cfg(test)]
mod control_port_tests {
use super::{parse_control_port, usage_exit_code};
@@ -234,124 +358,152 @@ mod control_port_tests {
}
}
#[cfg(test)]
mod launch_args_tests {
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
use std::ffi::OsString;
fn parse(values: &[&str]) -> LaunchArgs {
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// Issue #6429: a missing value must fail the launch, not silently fall back to a
// generated token or to no root.
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
let parsed = parse(&[flag]);
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
}
}
#[test]
fn missing_value_does_not_clear_an_environment_default() {
// `--automation-read-root` with no value must not drop a root set through the env var.
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
}
#[test]
fn values_are_parsed_and_no_errors_remain() {
let parsed = parse(&[
"--control",
"50494",
"--control-token",
"abcd",
"--control-token-file",
"/tmp/token",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"mailbox",
"photo.psd",
]);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
assert_eq!(parsed.control_port, Some(50494));
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu);
assert!(parsed.in_window_menus);
assert!(parsed.new_instance);
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
}
#[test]
fn version_stops_parsing() {
let parsed = parse(&["--version", "--control"]);
assert!(parsed.version);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let parsed = parse(&["-h", "--safegpu", "--control"]);
assert!(parsed.help);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
assert!(!parsed.help);
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut control_port: Option<u16> = None;
let mut usage_errors: Vec<String> = Vec::new();
let mut launch = LaunchArgs {
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
..Default::default()
};
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => usage_errors.push(error),
Ok(port) => launch.control_port = Some(port),
Err(error) => launch.errors.push(error),
}
}
let mut control_token = None;
let mut control_token_file = None;
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
let mut files = Vec::new();
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let mut new_instance = false;
// Options that apply only to the process they're given to; a launch with any keeps its own.
let mut launch_options: Vec<&'static str> = Vec::new();
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => files.push(s),
Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
print_stdout(USAGE);
return Ok(());
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => control_port = Some(port),
Err(error) => usage_errors.push(error),
},
None => usage_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => control_token = Some(token),
Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
Some("--automation-read-root") => {
launch_options.push("--automation-read-root");
automation_read_root = args.next().map(std::path::PathBuf::from);
}
Some("--automation-write-root") => {
launch_options.push("--automation-write-root");
automation_write_root = args.next().map(std::path::PathBuf::from);
}
Some("--safe-gpu") => {
launch_options.push("--safe-gpu");
safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => present_mode_arg = Some(v),
"--frame-latency" => frame_latency_arg = Some(v),
_ => stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
launch_options.push("--in-window-menus");
in_window_menus = true;
}
Some("--new-instance") => new_instance = true,
Some("--version") => {
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => files.push(s.to_owned()),
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
}
launch = parse_launch_args(std::env::args_os().skip(1), launch);
if launch.help {
print_stdout(USAGE);
return Ok(());
}
if launch.version {
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// A malformed control port must not silently drop the control server (issue #701), and an
// unknown option must not open the window (#6431): name the bad argument and fail the launch,
// like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage
// error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&usage_errors) {
for error in &usage_errors {
// A malformed control port must not silently drop the control server (issue #701), a control
// or automation flag without its value must not silently fall back to a generated token or
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
// status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&launch.errors) {
for error in &launch.errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
let LaunchArgs {
control_port,
control_token,
control_token_file,
automation_read_root,
automation_write_root,
files,
unreadable_paths,
safe_gpu,
new_instance,
mut launch_options,
present_mode_arg,
frame_latency_arg,
stroke_trace_arg,
in_window_menus,
help: _,
version: _,
errors: _,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
@@ -363,6 +515,11 @@ fn main() -> eframe::Result {
}
}
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
@@ -474,6 +631,11 @@ fn main() -> eframe::Result {
let tablet_monitor = std::cell::OnceCell::new();
#[cfg(target_os = "macos")]
let tablet_monitor = &tablet_monitor;
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
#[cfg(target_os = "windows")]
let tablet_monitor_win = std::cell::OnceCell::new();
#[cfg(target_os = "windows")]
let tablet_monitor_win = &tablet_monitor_win;
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
@@ -525,7 +687,9 @@ fn main() -> eframe::Result {
}
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
let wanted_adapter =
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
@@ -621,9 +785,16 @@ fn main() -> eframe::Result {
info.canvas = "cpu".into();
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);
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
// Keep an earlier note, such as a requested adapter that couldn't be used.
let software = "No compatible hardware graphics adapter; using software graphics.";
let fallback = &mut app.perf.gpu_info.fallback;
*fallback = Some(match fallback.take() {
Some(earlier) => format!("{earlier}. {software}"),
None => software.into(),
});
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
@@ -700,8 +871,13 @@ fn main() -> eframe::Result {
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
let _ = tablet_monitor.set(monitor);
}
#[cfg(target_os = "windows")]
if let Some(monitor) = tablet::install_windows() {
let _ = tablet_monitor_win.set(monitor);
}
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
app.services.right_button_down = tablet::right_button_service();
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
@@ -767,6 +943,19 @@ fn main() -> eframe::Result {
result
}
/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock
/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file,
/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held
/// for a moment.
#[cfg(test)]
pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(());
/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile.
#[cfg(test)]
pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> {
CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
#[test]
+1
View File
@@ -495,6 +495,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
+6 -1
View File
@@ -36,17 +36,22 @@ fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std:
/// once the user is done with it (here after 300 ms).
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead;
let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut daemon =
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
drop(spawns);
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let seen: Seen = Arc::default();
let log = seen.clone();
// A stream only gets the messages that arrive after it exists: made in the thread, it could
// miss a `SaveFile` sent before the thread got going, and the call would wait forever.
let messages = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
for msg in messages {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
+157 -32
View File
@@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -20,9 +20,9 @@ mod recovery_tests;
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "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",
"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",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -36,16 +36,48 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder
/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of
/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native
/// picker defaults to another filter.
const OPEN_FILTERS: &[(&str, &[&str])] = &[
("PhotoCraft", &["pcraft"]),
("Photoshop", &["psd", "psb", "psdt"]),
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
("Krita", &["kra", "krz"]),
("GIMP", &["xcf", "xcfgz", "gz"]),
("OpenRaster", &["ora"]),
("Paint.NET", &["pdn"]),
("SVG", SVG_EXTS),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("HEIF", &["heic", "heif", "hif"]),
("TIFF", &["tif", "tiff"]),
("WebP", &["webp"]),
("GIF", &["gif"]),
("BMP", &["bmp"]),
("Targa", &["tga"]),
("ICO", &["ico"]),
("QOI", &["qoi"]),
("PCX", &["pcx"]),
("DDS", &["dds"]),
("XPM", &["xpm"]),
("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
("OpenEXR", &["exr"]),
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
("DPX and Cineon", &["dpx", "cin"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Swatches", &["aco", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
vec![
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
.chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
.collect()
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
@@ -68,6 +100,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -160,10 +194,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
} else {
open_filters()
.into_iter()
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
};
if multiple {
let picked = dialog.pick_files();
@@ -244,7 +275,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
fn block_on<T>(future: impl Future<Output = T>) -> T {
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
@@ -265,7 +296,7 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
@@ -280,8 +311,9 @@ pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
/// Crash-recovery autosaves: under the settings directory, except where that is synced with the
/// settings: the Windows roaming profile and the Linux config folder (see
/// [`crate::app_dirs::resolve_recovery`]).
fn recovery_dir() -> Option<PathBuf> {
crate::app_dirs::recovery_dir()
}
@@ -464,6 +496,7 @@ fn shell_word(s: &str) -> String {
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@@ -537,26 +570,47 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}),
clipboard_get_image: Some({
let clip = clip.clone();
let unreadable = clip_unreadable.clone();
Box::new(move || {
let mut slot = clip.try_borrow_mut().ok()?;
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
None => {
// arboard opens only when some backend works. In a Flatpak on Wayland none
// does (#5031): the sandbox hides the data-control protocol from it, and
// `--socket=fallback-x11` gives no X server while a compositor runs.
let opened = arboard::Clipboard::new();
unreadable.set(opened.is_err());
slot.insert(opened.map_err(|e| e.to_string())?)
}
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
match cb.get().file_list() {
Ok(paths) => {
if let Some(img) = image_from_files(&paths) {
return Ok(Some(img));
}
}
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
Err(_) => {}
}
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
match cb.get_image() {
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
Err(error) => {
#[cfg(target_os = "linux")]
if let Some(image) = wayland_clipboard_image() {
return Ok(Some(image));
}
match error {
arboard::Error::ContentNotAvailable => Ok(None),
_ => Err(error.to_string()),
}
}
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
})
}),
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
@@ -695,6 +749,57 @@ mod tests {
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
}
/// Regression using the real clipboard on an isolated Windows CI runner.
#[cfg(target_os = "windows")]
#[test]
#[ignore = "requires exclusive use of the Windows clipboard"]
fn busy_native_clipboard_preserves_copied_shape() {
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
let mut native = native(None);
let services = Services {
clipboard_set_image: native.clipboard_set_image.take(),
clipboard_get_image: native.clipboard_get_image.take(),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
// A non-null window distinguishes this owner from arboard's null-window opens.
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
ready_tx.send(()).unwrap();
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
drop(guard);
});
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
let contention = arboard::Clipboard::new().unwrap().get_image().err();
let first = app.run(id, json!({}));
release_tx.send(()).unwrap();
holder.join().unwrap();
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
eprintln!("paste while clipboard occupied: {first:?}");
assert!(first.is_err(), "report contention rather than a successful no-op");
assert!(
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
"a temporary OS read failure must not discard the copied editable shape"
);
app.run(id, json!({})).unwrap();
let state = app.session.active().unwrap();
assert!(
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
"retry keeps the shape editable"
);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
@@ -729,6 +834,26 @@ mod tests {
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
/// #6337: after All Formats, every format File › Open reads has its own filter, and no filter
/// offers an extension All Formats doesn't.
#[test]
fn every_open_format_has_its_own_filter() {
let filters = open_filters();
assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first");
let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect();
let total = named.len();
named.sort_unstable();
named.dedup();
assert_eq!(named.len(), total, "an extension is listed under two filters");
let mut all = OPEN_EXTS.to_vec();
all.sort_unstable();
assert_eq!(named, all, "the named filters cover All Formats exactly");
let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect();
for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] {
assert!(names.contains(&name), "{name}");
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
#[test]
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
@@ -844,7 +969,7 @@ mod tests {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
for ext in ["ico", "gif", "bmp", "dds"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
+29 -6
View File
@@ -1,10 +1,12 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
//! barrel button on Windows (#5905).
//!
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force.
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
//! button through a thread-local message hook beside winit.
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
#[allow(dead_code)]
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
#[allow(dead_code)]
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
/// returns). On failure this logs why: the pen barrel button is then missed.
#[cfg(target_os = "windows")]
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
}
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
#[cfg(target_os = "windows")]
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
}
#[cfg(not(target_os = "windows"))]
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
None
}
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
+117 -4
View File
@@ -20,6 +20,11 @@
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
@@ -29,7 +34,20 @@ pub use platform::service;
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::WindowFrameFn;
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
use winsafe::prelude::Handle;
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
type Rect = (i32, i32, i32, i32);
/// A monitor edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Edge {
Left,
Top,
Right,
Bottom,
}
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
/// system title bar (the window is decorated) or its handle can't be found.
@@ -99,8 +117,11 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some((left, top, right, bottom)) =
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
// covers the whole monitor, which a trimmed maximize no longer does.
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
else {
return;
};
@@ -153,6 +174,69 @@ mod platform {
(corrected != rect).then_some(corrected)
}
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
fn covers(rect: Rect, monitor: Rect) -> bool {
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
}
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
let mut edges = Vec::new();
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
let mut after: Option<HWND> = None;
// One taskbar per monitor; the bound only guards against a misbehaving shell.
for _ in 0..64 {
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
break;
};
if let Ok(r) = tray.GetWindowRect() {
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
}
after = Some(tray);
}
}
edges
}
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
return None;
}
// Doubled centre and distances, so nothing rounds.
let (cx, cy) = (tl + tr, tt + tb);
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
.into_iter()
.min_by_key(|&(_, distance)| distance)
.map(|(edge, _)| edge)
}
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
/// `None` when there is nothing to trim.
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
if !covers(rect, monitor) {
return None;
}
let (mut l, mut t, mut r, mut b) = rect;
for edge in taskbars {
match edge {
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
_ => {}
}
}
let trimmed = (l, t, r, b);
(l < r && t < b && trimmed != rect).then_some(trimmed)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
@@ -162,7 +246,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_rect};
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
use winsafe::co;
#[test]
@@ -221,6 +305,35 @@ mod platform {
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
#[test]
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
// #4845: an auto-hidden taskbar reserves no work area.
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (edge, expected) in
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
{
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
}
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
// A taskbar that reserves its work area is the clamp's job.
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
}
#[test]
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<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"
>
<ellipse cx="12" cy="12" rx="10" ry="7" stroke-dasharray="3.5 3.23" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+4 -4
View File
@@ -9,8 +9,8 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
<path d="m14 10 2.1 2.1" />
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
</svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 502 B

+3
View File
@@ -5,6 +5,9 @@
- **Headless:** an in-process `Headless` session owns the engine and performs file I/O.
- **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control <port>`.
For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register).
The installed binary survives `cargo clean`; a symlink into `target/release` does not.
Start the headless server with:
```sh
+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.
- 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
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
+26
View File
@@ -159,9 +159,35 @@ sync_github_name = false
real_name = "Ufuk Efe"
sync_github_name = true
[people.nicolasfalesy]
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
[people.TaylorBurke]
sync_github_name = true
[people.1Git2Clone]
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.Byxis]
sync_github_name = true
[people.00xJS]
sync_github_name = true
[people.guillehr2]
real_name = "Guillem Hermida"
sync_github_name = true
[people.fjamesprice]
sync_github_name = true
[people.ha1des]
sync_github_name = true
+8 -1
View File
@@ -139,11 +139,18 @@ zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across
the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot
visits (including nulls and non-object values), and four million points created or copied into
compounds. Operand slots are charged before filtering, without first collecting an operand list.
A compound that crosses a limit or contains a cycle is omitted
with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
+93 -17
View File
@@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
const MAX_COMPOUND_DEPTH: usize = 64;
const MAX_OUTLINE_VISITS: usize = 500_000;
const MAX_OUTLINE_POINTS: usize = 4_000_000;
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
/// transform. Bound work across the entire document instead of memoizing by object id alone.
pub(crate) struct Budget {
visits_left: usize,
points_left: usize,
}
impl Default for Budget {
fn default() -> Self {
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
}
}
impl Budget {
/// Charge an outline call or a raw compound operand slot before inspecting it.
fn visit(&mut self) -> Result<(), &'static str> {
self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?;
Ok(())
}
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
let Some(left) = self.points_left.checked_sub(points) else {
// Do not rebuild an expensive leaf for every subsequent root after exhaustion.
self.visits_left = 0;
return Err("compound geometry budget exhausted");
};
self.points_left = left;
Ok(())
}
}
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
match outline_inner(r, id, class, world, &mut Vec::new()) {
Ok(path) => path,
Err(reason) => {
r.warn(reason);
None
}
}
}
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
r.geometry_budget.visit()?;
if active.contains(&id) {
return Err("cyclic compound shape");
}
// Independent of the layer-tree limit: Comp operands do not pass through Reader::node.
if active.len() >= MAX_COMPOUND_DEPTH {
return Err("compound depth exceeds 64 levels");
}
if class != Tag::of(b"Comp") {
let path = leaf_outline(r, id, class, world);
if let Some((path, _)) = &path {
r.geometry_budget.path(path)?;
}
return Ok(path);
}
active.push(id);
let result = compound(r, id, world, active);
active.pop();
result
}
fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
let s = r.s;
// One path of all operands' contours, filled even-odd, as before. A safety failure rejects
// the whole outline: a partial add/subtract shape could silently change its meaning.
let mut path = Path::default();
// Charge every slot before filtering nulls or malformed values. Stream::objs would scan
// and allocate the whole list first, repeating uncharged work for each shared occurrence.
let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] };
for child in children {
r.geometry_budget.visit()?;
let Value::Obj(Some(c)) = child else { continue };
let c = *c;
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let Some(class) = s.class(c) else { return Ok(None) };
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? {
r.geometry_budget.path(&p)?;
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Ok(Some((path, true)))
}
fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
@@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
+11 -1
View File
@@ -298,6 +298,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: 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
/// far hold).
pixels_left: usize,
@@ -305,7 +306,16 @@ pub(crate) struct Reader<'s, 'a, 'b> {
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
}
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
+16 -1
View File
@@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
}
}
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
/// objects, so one can link to itself, and with both links followed at every level a colour read
/// costs up to 2^(depth+1) lookups.
const MAX_SPOT_DEPTH: usize = 4;
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
color_at(r, id, 0)
}
/// [`color`] of an object `depth` spot colours down.
fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
@@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
if depth >= MAX_SPOT_DEPTH {
r.warn("spot colours nested deeper than 4 levels (left empty)");
return None;
}
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
+171
View File
@@ -538,3 +538,174 @@ mod pixels {
);
}
}
/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB
/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process
/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded
/// recursion fails the one test that caused it instead of taking the other tests in the binary
/// down with it.
mod spot_colours {
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::paint::{Color, Paint};
use crate::stream::Tag;
use crate::synth::{self, F, Method, tag};
/// A one-page document whose page holds `top`, read in a child process first and then here.
fn read(top: Vec<F>) -> Document {
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]);
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
let test = std::thread::current().name().unwrap_or("reader").to_owned();
read_in_child(&bytes, &test);
read_on_small_stack(bytes, &test).unwrap()
}
/// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test.
fn read_on_small_stack(bytes: Vec<u8>, test: &str) -> Result<Document, crate::Error> {
let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap();
reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
/// Set in the child process to the name of the test it reads for.
const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD";
/// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if
/// that process crashes, panics or takes over 20 s, or never read the document.
fn read_in_child(bytes: &[u8], test: &str) {
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"])
.env(CHILD, test)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let drain = |mut pipe: Box<dyn Read + Send>| {
std::thread::spawn(move || {
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
};
let stdout = drain(Box::new(child.stdout.take().unwrap()));
let stderr = drain(Box::new(child.stderr.take().unwrap()));
// Fed from a thread, so a child that never reads cannot block the deadline below; a child
// that dies first breaks the pipe and is reported by its exit status.
let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec());
let feed = std::thread::spawn(move || {
let _ = stdin.write_all(&input);
});
let deadline = Instant::now() + Duration::from_secs(20);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break Some(status);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(Duration::from_millis(10));
};
feed.join().unwrap();
let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap());
match status {
None => panic!("reading the document did not finish within 20 s:\n{output}"),
Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"),
// A filter that matched no test would also exit successfully, having read nothing.
Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"),
}
}
/// Not a test of its own: `read_in_child` runs it in a child process to read the document on
/// its stdin. Without the variable, as when run with `--ignored`, it does nothing.
#[test]
#[ignore = "run by read_in_child in a child process"]
fn read_child() {
let Some(test) = std::env::var_os(CHILD) else { return };
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes).unwrap();
let len = bytes.len();
let _ = read_on_small_stack(bytes, &test.to_string_lossy());
println!("read {len} bytes");
}
/// An ellipse filled with `colour`.
fn filled(id: u32, colour: F) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])),
(tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])),
(tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])),
],
)
}
fn spot(id: u32, fields: Vec<(Tag, F)>) -> F {
F::Def(id, vec![tag(b"Pant")], fields)
}
fn fills(doc: &Document) -> &[Paint] {
match &doc.spreads[0].nodes[0].kind {
Kind::Shape { fills, .. } => fills,
other => panic!("expected a shape, got {other:?}"),
}
}
fn warned(doc: &Document, what: &str) -> bool {
doc.warnings.iter().any(|w| w.contains(what))
}
#[test]
fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() {
let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
#[test]
fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() {
let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
#[test]
fn spot_colours_of_spot_colours_do_not_take_exponential_time() {
// Each level's RGB equivalent and base are the next level, and the last is in an unknown
// model, so both are followed everywhere: 2^41 colour reads before the fix.
fn level(id: u32, left: u32) -> F {
if left == 0 {
return F::Def(id, vec![tag(b"XXXX")], vec![]);
}
spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))])
}
let doc = read(vec![filled(10, level(100, 40))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
}
/// `spots` spot colours, each defined through the next, around an opaque RGB colour.
fn spots(id: u32, spots: u32) -> F {
if spots == 0 {
let rgba: Vec<u8> = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect();
return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]);
}
spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))])
}
#[test]
fn spot_colours_nest_four_levels_and_no_deeper() {
let doc = read(vec![filled(10, spots(100, 4))]);
assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]);
let doc = read(vec![filled(10, spots(100, 5))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
}
+212
View File
@@ -0,0 +1,212 @@
//! #6423: compound operands form a graph, not necessarily a tree.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use photocraft_affinity::model::{Document, Kind};
use photocraft_affinity::synth::{self, F, Method, tag};
use photocraft_affinity::{Limits, read};
use std::time::{Duration, Instant};
fn compound(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
}
fn rectangle() -> F {
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))])
}
fn document_bytes(definitions: Vec<F>, roots: Vec<F>) -> Vec<u8> {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))],
)]),
)],
),
),
]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn document(definitions: Vec<F>, roots: Vec<F>) -> Document {
read(&document_bytes(definitions, roots), Limits::default()).unwrap()
}
// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang)
// inside a child process, so the regression fails without killing the entire test runner.
fn isolated(name: &str, check: impl FnOnce()) {
if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) {
check();
return;
}
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", name, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
let start = Instant::now();
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success(), "compound import failed: {status}");
break;
}
if start.elapsed() > Duration::from_secs(10) {
child.kill().unwrap();
child.wait().unwrap();
panic!("compound import exceeded ten seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn unsupported(doc: &Document, warning: &str) {
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported));
assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings);
}
#[test]
fn self_referencing_compound_is_reported_without_aborting() {
isolated("self_referencing_compound_is_reported_without_aborting", || {
let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]);
unsupported(&doc, "cyclic compound");
assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes");
});
}
#[test]
fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() {
isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || {
let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]);
unsupported(&doc, "cyclic compound");
});
}
fn nested(levels: u32, repeated: bool) -> Document {
// Flat definitions bypass the stream parser's nesting cap: only references form the depth.
let mut definitions = vec![rectangle()];
let mut previous = 100;
for id in 200..200 + levels {
let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] };
definitions.push(compound(id, children));
previous = id;
}
document(definitions, vec![F::Ref(previous)])
}
#[test]
fn deep_compound_graph_is_bounded_on_a_small_stack() {
isolated("deep_compound_graph_is_bounded_on_a_small_stack", || {
std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap();
});
}
#[test]
fn shared_operands_cannot_expand_exponentially() {
isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget"));
}
#[test]
fn shared_empty_operands_also_have_a_work_limit() {
isolated("shared_empty_operands_also_have_a_work_limit", || {
let mut definitions = vec![compound(100, vec![])];
let mut previous = 100;
for id in 200..240 {
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
previous = id;
}
unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget");
});
}
#[test]
fn geometry_budget_is_shared_across_layer_roots() {
isolated("geometry_budget_is_shared_across_layer_roots", || {
let mut definitions = vec![rectangle()];
let mut previous = 100;
for id in 200..214 {
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
previous = id;
}
let mut roots = vec![F::Ref(previous); 12];
roots.push(F::Ref(100));
let doc = document(definitions, roots);
let nodes = &doc.spreads[0].nodes;
assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports");
assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget");
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
});
}
#[test]
fn ordinary_shared_operands_keep_both_transformed_copies() {
let mut translated = compound(201, vec![F::Ref(100)]);
let F::Def(_, _, fields) = &mut translated else { unreachable!() };
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0])));
let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]);
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
assert_eq!(path.subpaths.len(), 2);
for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) {
assert_eq!(b.x, a.x + 30.0);
assert_eq!(b.y, a.y + 40.0);
}
}
#[test]
fn null_operand_slots_cannot_bypass_geometry_budget() {
isolated("null_operand_slots_cannot_bypass_geometry_budget", || {
let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]);
let start = Instant::now();
let doc = read(&bytes, Limits::default()).unwrap();
eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64());
unsupported(&doc, "compound geometry budget");
assert_eq!(doc.spreads[0].nodes.len(), 20_000);
assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported)));
});
}
#[test]
fn null_operand_slots_share_budget_across_layer_roots() {
isolated("null_operand_slots_share_budget_across_layer_roots", || {
let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]);
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid");
assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported));
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
});
}
#[test]
fn null_operand_slots_cannot_leave_a_partial_shape() {
isolated("null_operand_slots_cannot_leave_a_partial_shape", || {
let mut slots = vec![F::Ref(100)];
slots.extend(vec![F::Null; 1_000_000]);
let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]);
unsupported(&doc, "compound geometry budget");
});
}
#[test]
fn non_object_operand_slots_also_consume_budget() {
isolated("non_object_operand_slots_also_consume_budget", || {
let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]);
unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget");
});
}
#[test]
fn ordinary_null_operand_slots_preserve_shared_contours() {
let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]);
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[0], path.subpaths[1]);
}
+42
View File
@@ -0,0 +1,42 @@
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
//! (3:2), each overlapping the next by half its width.
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
use std::time::Instant;
use photocraft_algo::panorama::{self, RoiImage};
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
fn peak_mib() -> Option<f64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024.0)
}
fn main() {
let mut args = std::env::args().skip(1);
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
let sw = sh * 3 / 2;
let step = sw / 2;
let (cw, ch) = (step * (n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..n)
.map(|k| {
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
})
.collect();
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
let wts: Vec<Vec<f32>> = (0..n)
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
.collect();
let levels = panorama::blend_levels(cw, ch).min(7);
let before = peak_mib();
let t0 = Instant::now();
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
std::hint::black_box((px, cov));
}
+69 -4
View File
@@ -212,12 +212,17 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
}
/// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`.
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) {
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. Stops early (leaving
/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of
/// milliseconds on a tall image, too long to finish once cancelled (#6205).
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) {
let Scratch { line, next, acc } = s;
let Some(len) = dst.len().checked_div(w) else { return };
let mut tail: usize = radii.iter().sum();
for (k, &r) in radii.iter().enumerate() {
if ctl.cancelled() {
return;
}
tail -= r;
if k + 1 == radii.len() {
box_pass(line, dst, w, r, acc);
@@ -281,7 +286,7 @@ pub(crate) fn box_blur(
(0..left).for_each(|_| s.line.extend_from_slice(first));
s.line.extend_from_slice(&row);
(0..right).for_each(|_| s.line.extend_from_slice(last));
line_passes(dst, n, &radii, &mut s);
line_passes(dst, n, &radii, &mut s, ctl);
}
});
// Vertical passes, strip by strip, the same way down the columns.
@@ -300,7 +305,10 @@ pub(crate) fn box_blur(
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
let mut res = vec![0.0f32; oh * w];
line_passes(&mut res, w, &radii, &mut s);
line_passes(&mut res, w, &radii, &mut s, ctl);
if ctl.cancelled() {
return (t, Vec::new());
}
unpremultiply(&mut res, n, alpha);
(t, res)
});
@@ -1180,4 +1188,61 @@ mod tests {
}
}
}
/// A column strip's passes stop between passes once cancelled, not only before the strip
/// (#6205); uncancelled, every pass runs.
#[test]
fn line_passes_stop_between_passes_when_cancelled() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (radii, w, len) = ([2usize, 3, 2], 4usize, 50usize);
let reach: usize = radii.iter().sum();
let line: Vec<f32> = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect();
let run = |ctl: &Interrupt| {
let mut s = Scratch { line: line.clone(), ..Scratch::default() };
let mut dst = vec![0.0f32; len * w];
line_passes(&mut dst, w, &radii, &mut s, ctl);
dst
};
assert!(run(&Interrupt::NONE).iter().all(|v| *v > 0.0));
let calls = AtomicUsize::new(0);
let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 1;
// Cancelled after the first pass: the last pass (the one writing `dst`) never runs.
assert!(run(&Interrupt::cancel_only(&cancel)).iter().all(|v| *v == 0.0));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// A box blur cancelled at any of its checks returns `None` (never a half-blurred result);
/// one that is asked but never cancelled is bit-identical to the uninterruptible run.
#[test]
fn box_blur_honours_a_cancel_at_every_check() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (n, src) = (4usize, Rect::new(0, 0, 150, 90));
let px: Vec<f32> = (0..src.width() as usize * src.height() as usize * n).map(|i| (i as f32 * 0.13).sin() * 0.5 + 0.5).collect();
let read = |r: Rect, buf: &mut Vec<f32>| {
buf.clear();
let (x0, x1) = ((r.x0 * n as i32) as usize, (r.x1 * n as i32) as usize);
let row = r.y0 as usize * src.width() as usize * n;
buf.extend_from_slice(&px[row + x0..row + x1]);
};
let Some(boxes) = box_widths(&FilterParams::GaussianBlur { radius: 12.0 }) else { panic!("box passes") };
let out = Rect::new(-5, 3, 155, 80);
let want = box_blur(src, &read, out, n, true, &boxes, &Interrupt::NONE).unwrap();
let checks = AtomicUsize::new(0);
let count = || {
checks.fetch_add(1, Ordering::SeqCst);
false
};
let got = box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&count)).unwrap();
assert_eq!(got.len(), want.len());
for ((ta, a), (tb, b)) in got.iter().zip(&want) {
assert_eq!(ta, tb);
assert!(a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()), "strip {ta:?} differs");
}
let total = checks.load(Ordering::SeqCst);
for k in (0..total).step_by((total / 40).max(1)) {
let seen = AtomicUsize::new(0);
let cancel = || seen.fetch_add(1, Ordering::SeqCst) >= k;
assert!(box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&cancel)).is_none(), "cancelled at check {k}");
}
}
}
+1 -1
View File
@@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
}
src.get(x, y, n - 1)
}
DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
};
Some(if spec.invert { 1.0 - d } else { d })
};
+2 -8
View File
@@ -67,22 +67,16 @@ impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
/// A value in this encoding as linear light.
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
/// Linear light back into this encoding, the inverse of [`Encoding::decode`].
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
fn srgb_encode(v: f32) -> f32 {
linear_to_srgb(v)
}
fn srgb_decode(v: f32) -> f32 {
srgb_to_linear(v)
}
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
+63 -6
View File
@@ -173,6 +173,39 @@ fn up(p: &Plane, w: usize, h: usize) -> Plane {
Plane { w, h, v: out }
}
/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
/// zero-stuffed row is all zeros).
fn up_rows(p: &Plane, h: usize, y: usize) -> [Option<usize>; 5] {
let lh = p.h.min(h.div_ceil(2));
std::array::from_fn(|k| {
let m = mirror(y as isize + k as isize - 2, h);
(m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
})
}
/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
/// It adds the same terms in the same order as `up`, so it gives the same bits.
fn up_at(p: &Plane, w: usize, rows: &[Option<usize>; 5], x: usize) -> f32 {
let lw = p.w.min(w.div_ceil(2));
let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2))
})
.sum()
} else {
(0..5)
.filter_map(|k| {
let m = mirror(x as isize + k as isize - 2, w);
(m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
})
.sum()
}
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
@@ -217,14 +250,14 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
rows(&mut lap[l], lw, |y, row| {
let taps = up_rows(coarse, lh, y);
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
*o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
}
}
});
@@ -244,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
// Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
// fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
let i = ((s.len() - 1) as f32 * q).round() as usize;
*s.select_nth_unstable_by(i, f32::total_cmp).1
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
@@ -444,11 +479,33 @@ mod tests {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
let full = up(&p, uw, uh);
assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
// The filter samples `up` one pixel at a time; every sample has the same bits.
for y in 0..uh {
let taps = up_rows(&p, uh, y);
for x in 0..uw {
assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
}
}
}
}
}
#[test]
fn quantile_is_the_sorted_element() {
let p = noise_plane(301, 211, 9);
let mut v = p.v.clone();
// Ties and signed zeros, which `total_cmp` orders by their bits.
v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
let mut sorted = v.clone();
sorted.sort_by(f32::total_cmp);
for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+2 -2
View File
@@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
+164
View File
@@ -566,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3
.collect()
}
/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of
/// diameter `size` along `points` (document px), adding to the sampling area or taking from it.
#[derive(Clone, Debug, PartialEq)]
pub struct SamplingStroke {
pub add: bool,
pub size: f64,
pub points: Vec<[f64; 2]>,
}
impl SamplingStroke {
/// Bounds of the pixels the stroke covers.
pub fn bounds(&self) -> Rect {
let r = (self.size / 2.0).max(0.5);
self.points.iter().fold(Rect::EMPTY, |acc, p| {
let b = Rect::new(
(p[0] - r).floor() as i32,
(p[1] - r).floor() as i32,
((p[0] + r).ceil() as i32).saturating_add(1),
((p[1] + r).ceil() as i32).saturating_add(1),
);
if acc.is_empty() { b } else { acc.union(&b) }
})
}
}
/// Squared distance from `p` to the segment `a`–`b`.
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]);
qx * qx + qy * qy
}
/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within
/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that
/// does not cover `area` exactly is left as it is.
pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) {
let w = area.width() as usize;
if mask.len() != w * area.height() as usize {
return;
}
for s in strokes {
let r = (s.size / 2.0).max(0.5);
let r2 = r.powi(2);
let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() {
[] => continue,
[p] => vec![(*p, *p)],
pts => pts.windows(2).map(|p| (p[0], p[1])).collect(),
};
// A stroke covers the union of its segments' pixels: each segment only looks at its own
// bounds.
for (a, b) in segs {
let bounds = Rect::new(
(a[0].min(b[0]) - r).floor() as i32,
(a[1].min(b[1]) - r).floor() as i32,
((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1),
((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1),
)
.intersect(&area);
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
let c = [f64::from(x) + 0.5, f64::from(y) + 0.5];
if seg_dist2(c, a, b) <= r2
&& let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
{
*m = s.add;
}
}
}
}
}
}
/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule):
/// what a lasso outlines.
pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec<bool> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut out = vec![false; w * h];
if pts.len() < 3 {
return out;
}
let mut xs: Vec<f64> = Vec::new();
for row in 0..h {
let cy = f64::from(area.y0) + row as f64 + 0.5;
xs.clear();
for i in 0..pts.len() {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
if (a[1] <= cy) != (b[1] <= cy) {
xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
}
}
xs.sort_by(f64::total_cmp);
for pair in xs.as_chunks::<2>().0 {
// Pixel centres x + 0.5 within [pair0, pair1).
let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true);
}
}
out
}
/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand
/// and Contract.
pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec<bool> {
if by == 0 || w == 0 || h == 0 {
return mask.to_vec();
}
let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX));
if by > 0 {
let d = crate::selection::edt(mask, w, h);
d.iter().map(|v| *v <= r).collect()
} else {
let outside: Vec<bool> = mask.iter().map(|v| !v).collect();
let d = crate::selection::edt(&outside, w, h);
d.iter().map(|v| *v > r).collect()
}
}
/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling
/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100).
pub fn default_brush_size(hole: Rect) -> f64 {
let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50)));
(0.4 * (w * h).sqrt()).round()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -577,6 +704,43 @@ mod tests {
(img, hole)
}
#[test]
fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() {
let area = Rect::new(10, 20, 50, 40);
let (w, h) = (40, 20);
let strokes = [
SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] },
SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] },
SamplingStroke { add: true, size: 2.0, points: Vec::new() },
];
let mut mask = vec![false; w * h];
paint_strokes(&mut mask, area, &strokes);
// The same pixels as testing every pixel against every segment of each stroke in turn.
let mut want = vec![false; w * h];
for s in &strokes {
let r2 = (s.size / 2.0).max(0.5).powi(2);
let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() };
for (i, m) in want.iter_mut().enumerate() {
let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5];
if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) {
*m = s.add;
}
}
}
assert_eq!(mask, want);
assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab");
// A mask shorter or longer than the area is not touched (and nothing panics).
for len in [0, 7, w * h - 1, w * h + 3] {
let mut short = vec![false; len];
paint_strokes(&mut short, area, &strokes);
assert!(short.iter().all(|v| !v), "len {len}");
}
// Hostile coordinates stay inside the area.
let mut m = vec![false; w * h];
let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }];
paint_strokes(&mut m, area, &wild);
}
#[test]
fn cancelled_fill_stops_and_progress_reaches_one() {
use std::sync::atomic::{AtomicU32, Ordering};
+36 -19
View File
@@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
}
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
/// unit-circle coordinates `(u, v)` and back.
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
let (w, h) = (b.width() as f32, b.height() as f32);
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
}
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = angle.to_radians();
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || rr <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let t = a * (1.0 - r / rr);
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 {
return (x, y);
}
let t = a * (1.0 - r);
let (s, c) = t.sin_cos();
(cx + dx * c - dy * s, cy + dx * s + dy * c)
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
})
}
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = r.powf(1.0 - a * 0.5) / r;
(cx + u * k * hx, cy + v * k * hy)
})
}
@@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
}
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
let b = ctx.bounds;
let (cx, cy, rr) = centre(b);
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
match mode {
SpherizeMode::Normal => {
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * sphere_1d(r / rr, a);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = (dx / hx, dy / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = sphere_1d(r, a) / r;
(cx + u * k * hx, cy + v * k * hy)
}
SpherizeMode::HorizontalOnly => {
if dx.abs() >= hx {
+2 -5
View File
@@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15;
struct Rng(u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
+3 -5
View File
@@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20;
#[derive(Clone)]
pub(crate) struct Rng(u64);
impl Rng {
/// A generator whose sequence is fixed by `seed`.
pub(crate) fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
}
/// The next value of the SplitMix64 sequence.
pub(crate) fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
pub(crate) fn skip(&mut self, n: u64) {
+24 -3
View File
@@ -482,7 +482,8 @@ pub enum FilterParams {
distribution: Distribution,
monochromatic: bool,
seed: u32,
/// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
/// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
/// coordinates), supplied by the caller.
#[serde(skip)]
depth_map: Option<std::sync::Arc<Image>>,
},
@@ -744,8 +745,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::AddNoise { amount, distribution, monochromatic, seed } => noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed),
FilterParams::Median { radius } => noise::median(src, out, *radius, None),
FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)),
FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve),
FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined),
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
@@ -909,6 +910,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
}
/// Minimum / Maximum in the document's polarity. CMYK samples are ink, so there Maximum spreads
/// paper white and Minimum spreads ink (as Photoshop does): the colour channels run inverted,
/// alpha keeps its usual polarity.
fn min_max_ink(src: &Image, out: Rect, ctx: &Ctx, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
if !fxutil::subtractive(ctx) {
return other::min_max_preserve(src, out, radius, max, preserve);
}
let n = src.ch.max(1);
let cc = if ctx.alpha { n - 1 } else { n };
let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v));
let mut inv = src.clone();
flip(&mut inv.data);
let mut res = other::min_max_preserve(&inv, out, radius, max, preserve);
flip(&mut res);
res
}
/// Output tile size used by [`apply`].
pub const TILE: i32 = 256;
@@ -972,6 +990,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
}
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
/// Nonpositive tile sizes are treated as one pixel.
pub fn apply_tiled(
surface: &Surface,
params: &FilterParams,
@@ -1014,6 +1033,8 @@ fn apply_tiled_with_impl(
ctl: &photocraft_raster::Interrupt,
prepare_fast_paths: bool,
) -> Option<Surface> {
// Every dispatch path needs a positive step, including Motion Blur's fallback.
let tile = tile.max(1);
if ctl.cancelled() {
return None;
}
+7 -3
View File
@@ -8,7 +8,8 @@ use crate::image::Image;
use crate::{Ctx, HsbModel};
/// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha
/// kept). Kernels shorter than 25 entries are zero-padded.
/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
let s = if scale == 0.0 { 1.0 } else { scale };
let off = offset / 255.0;
let sub = crate::fxutil::subtractive(ctx);
let mut res = src.crop(out);
if k.is_empty() {
return res;
@@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
for (c, v) in px.iter_mut().enumerate().take(cc) {
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
*v = sum / s + off;
// CMYK: convolve brightness (1 - ink), then back to ink.
let lum = |v: f32| if sub { 1.0 - v } else { v };
let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum();
*v = lum(sum / s + off);
}
}
res
+17 -4
View File
@@ -89,10 +89,7 @@ fn composite_area(
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
let px = &mut region[i..i + n];
let d = photocraft_raster::to_rgba(&fmt, px);
let mut o = blend::composite(blend_mode, d, color(x, y), k);
if lock_transparency {
o[3] = d[3];
}
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
@@ -296,6 +293,22 @@ mod tests {
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");
}
#[test]
fn locked_gradient_blends_source_atop_on_semi_transparent_pixels() {
// Locked half-transparent white, flat black gradient at 50%: colour halfway, alpha kept (#5783).
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
for mode in [BlendMode::Normal, BlendMode::Multiply] {
let mut s = Surface::new(fmt);
let a = Rect::new(0, 0, 4, 1);
s.fill_rect(a, &[1.0, 1.0, 1.0, 0.5]);
let black = [0.0, 0.0, 0.0, 1.0];
paint_gradient(&mut s, a, (0.0, 0.0), (4.0, 0.0), GradientShape::Linear, &[(0.0, black), (1.0, black)], false, 0.5, mode, false, None, true);
let p = s.rgba(2, 0);
assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}");
}
}
}
#[test]
fn bucket_fills_contiguous_region() {
let mut s = Surface::new(PixelFormat::RGBA8);
+111 -45
View File
@@ -1170,10 +1170,28 @@ fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize
})
}
/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built
/// and accumulated in batches under it, so its peak memory doesn't grow with the image count
/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side
/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
#[cfg(not(target_arch = "wasm32"))]
const BAND_BUDGET: usize = 5 << 30;
/// Without threads the bands of a batch are built one after another anyway, so each is added as
/// soon as it is built (the browser's address space is 4 GB).
#[cfg(target_arch = "wasm32")]
const BAND_BUDGET: usize = 0;
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
/// channels (`ch` per pixel) and the total coverage.
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
}
/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize, budget: usize) -> (Vec<f32>, Vec<f32>) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
@@ -1187,13 +1205,22 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let grid = |im: &RoiImage| {
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
};
// Building one band holds its grid's colour and weight planes about three times over: the
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
let cost = |im: &RoiImage| {
let (_, _, gw, gh) = grid(im);
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
};
let band = |k: usize| -> PyramidBand {
let im = &imgs[k];
let (gx0, gy0, gw, gh) = grid(im);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
// most one), so rows of the grid are independent and fill in parallel.
let src_of = |gx: usize, gy: usize| -> Option<usize> {
@@ -1225,68 +1252,85 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
});
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
drop(cov);
push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
drop(bin);
// Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
// than being copied: with one image built at a time, a copy is a serial pass over it.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
let mut gau: Vec<Vec<f32>> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
lap.push((cw, chh, cur));
gau.push(cwt);
break;
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
gau.push(cwt.clone());
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
gau.push(cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
});
// Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
// level, bands one after another), but rows of a level are independent, so they run in
// parallel: every row walks the bands itself.
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
for c in 0..ch {
row[px * ch + c] += k * band[x * ch + c];
};
// Build the bands a batch at a time and add each batch before building the next. Each pixel
// adds the bands in the same order as the serial loop (per level, bands one after another;
// batches follow each other in image order), but rows of a level are independent, so they
// run in parallel: every row walks the batch's bands itself.
let mut start = 0;
while start < imgs.len() {
let mut end = start + 1;
let mut bytes = cost(&imgs[start]);
while let Some(im) = imgs.get(end)
&& bytes.saturating_add(cost(im)) <= budget
{
bytes = bytes.saturating_add(cost(im));
end += 1;
}
let bands = par_map(end - start, |j| band(start + j));
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
}
});
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
for c in 0..ch {
row[px * ch + c] += k * band[x * ch + c];
}
}
}
});
}
start = end;
}
for l in 0..levels {
let pw = sizes[l].0;
@@ -1910,6 +1954,28 @@ mod parallel_tests {
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
/// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
/// (one image per batch) up to everything in steps of a fifth pass through every split of the
/// three images.
#[test]
fn multiband_batches_match_the_serial_code() {
let budgets: Vec<usize> =
std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect();
for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64);
for levels in [1usize, 3, 5] {
let want = ref_multiband(w, h, &imgs, &wts, levels);
for &budget in &budgets {
let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
}
}
}
}
}
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
/// they hold on every platform).
#[test]
+3
View File
@@ -182,6 +182,9 @@ pub enum DepthSource {
None,
Transparency,
LayerMask,
/// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the
/// caller like the layer mask.
Channel,
}
/// Built-in Shape Blur kernels.
+47 -14
View File
@@ -159,15 +159,21 @@ pub(crate) fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -
(0..n).map(f).collect()
}
/// Advances `state` one SplitMix64 step and returns the mixed output.
pub(crate) fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Deterministic 64-bit generator (SplitMix64).
pub(crate) struct Rng(pub u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
pub fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
splitmix64(&mut self.0)
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
@@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
(z >> 40) as f32 / (1u64 << 24) as f32
}
/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1).
pub(crate) fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// sRGB transfer function, encode.
pub(crate) fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
/// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges.
pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 {
@@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] {
mod tests {
use super::*;
/// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit.
#[test]
fn splitmix64_output_is_unchanged() {
/// The SplitMix64 step as each generator inlined it before.
fn inlined(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
let mut zero = 0;
assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]);
for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] {
let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed));
for _ in 0..10_000 {
let want = inlined(&mut copy);
assert_eq!(splitmix64(&mut shared), want);
assert_eq!(rng.next(), want);
}
}
}
/// The shared sRGB functions match the removed copies bit for bit.
#[test]
fn srgb_transfer_output_is_unchanged() {
let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
for i in -2000..=6000 {
let v = i as f32 / 4000.0;
assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}");
assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}");
}
}
#[test]
fn dense_and_sparse_solvers_agree() {
// Overdetermined line fit y = 2x + 1.
+77
View File
@@ -562,6 +562,46 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
}
}
/// The threshold map of Bitmap's Custom Pattern from a pattern's lightness (0..=1, row-major):
/// pattern pixels ranked by lightness, ties sharing their average rank, the lightest pixel
/// thresholding lowest (it turns white first). A map of `n` pixels thus spreads `n` evenly
/// spaced levels `(k + 0.5) / n`, whatever the pattern's own contrast, as Photoshop's does
/// (measured: a 2x2 pattern of 32/96/160/224, a 3x1 of 50/128/200 and a 7x1 ramp all give
/// exactly those levels).
pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
let n = lightness.len();
let key = |i: usize| lightness.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|a, b| key(*a).total_cmp(&key(*b)));
let mut out = vec![0.5; n];
let mut start = 0;
while start < n {
let v = order.get(start).map(|i| key(*i));
let end = (start..n).find(|&j| order.get(j).map(|i| key(*i)) != v).unwrap_or(n);
// Ranks start..end share their mean.
let rank = (start + end - 1) as f32 / 2.0;
let t = 1.0 - (rank + 0.5) / n as f32;
for i in order.get(start..end).unwrap_or_default() {
if let Some(o) = out.get_mut(*i) {
*o = t;
}
}
start = end;
}
out
}
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
for (i, v) in gray.iter_mut().enumerate() {
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
*v = if *v >= t { 1.0 } else { 0.0 };
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -722,4 +762,41 @@ mod tests {
}
}
}
/// The first 8-bit gray level (of a 0..=255 ramp) that `t` turns white.
fn first_white(t: f32) -> u32 {
(0..=255).find(|v| *v as f32 / 255.0 >= t).unwrap_or(256)
}
#[test]
fn custom_pattern_thresholds_match_photoshop() {
// Photoshop (Image › Mode › Bitmap, Custom Pattern) on a 0..255 ramp: the first white
// level per pattern pixel, measured against defined patterns.
let l = |v: &[u8]| v.iter().map(|v| f32::from(*v) / 255.0).collect::<Vec<_>>();
let ramp7 = custom_pattern_thresholds(&l(&[0, 40, 80, 120, 160, 200, 240]));
assert_eq!(ramp7.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [237, 201, 164, 128, 92, 55, 19]);
let five = custom_pattern_thresholds(&l(&[10, 200, 90, 250, 150]));
assert_eq!(five.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [230, 77, 179, 26, 128]);
// Equal pixels share their rank: 100, 100 both sit at 50%.
let ties = custom_pattern_thresholds(&l(&[100, 100, 200, 50]));
assert_eq!(ties.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [128, 128, 32, 224]);
assert!(custom_pattern_thresholds(&[]).is_empty());
assert_eq!(custom_pattern_thresholds(&[f32::NAN, 0.3]).len(), 2);
}
#[test]
fn custom_pattern_tiles_from_the_top_left() {
// A 2x1 map: even columns need 75%, odd ones 25%.
let map = [0.75, 0.25];
let mut g = vec![0.5; 6];
to_bitmap_custom(&mut g, 3, &map, 2, 1);
assert_eq!(g, [0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
// A map that doesn't match its size, or an empty image width, is a 50% threshold.
let mut g = vec![0.4, 0.6];
to_bitmap_custom(&mut g, 2, &map, 3, 1);
assert_eq!(g, [0.0, 1.0]);
let mut g = vec![0.6];
to_bitmap_custom(&mut g, 0, &map, 2, 1);
assert_eq!(g, [1.0]);
}
}
+3 -2
View File
@@ -146,8 +146,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen
let mut c = rgba(ctx, px);
for k in 0..3 {
let a = (f[k] * bright).max(0.0);
// Screen keeps highlights below white for display-referred values.
c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0));
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit
// value already above white is left as is rather than pulled down towards it.
c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0);
}
set_rgba(ctx, px, c);
}
+3 -5
View File
@@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O
pub struct Rng(u64);
impl Rng {
/// A generator whose sequence is fixed by `seed` and `salt`.
pub fn new(seed: u32, salt: u64) -> Rng {
Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D)
}
/// The next value of the SplitMix64 sequence.
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
/// Uniform in [0, 1).
pub fn f(&mut self) -> f32 {
+29 -12
View File
@@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
// Horizontal pass over the window rows extended vertically by r.
// Horizontal pass over the window rows extended vertically by r. Rows are independent, so
// both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
let horizontal = |row: &mut Vec<f32>, (ty, dst): (usize, &mut [f32])| {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
@@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
}
}
}
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
// Contiguous accumulation can be vectorized for any channel layout. Each output still
// sums the same products in the same kernel order as the scalar convolution.
for (i, kv) in k.iter().enumerate() {
@@ -153,26 +153,43 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
*d += s * kv;
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
tmp.par_chunks_mut(stride).enumerate().for_each_init(scratch, horizontal);
}
#[cfg(target_arch = "wasm32")]
{
let mut row = scratch();
tmp.chunks_mut(stride).enumerate().for_each(|item| horizontal(&mut row, item));
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
let vertical = |(oy, dst): (usize, &mut [f32])| {
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
*d += s * kv;
}
}
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
if let Some(a) = alpha {
for (ox, px) in dst.chunks_exact_mut(ch).enumerate() {
let av = px[a];
for (c, v) in px.iter_mut().enumerate() {
if c != a {
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
*v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(stride).enumerate().for_each(vertical);
}
#[cfg(target_arch = "wasm32")]
{
out.chunks_mut(stride).enumerate().for_each(vertical);
}
out
}
+6 -2
View File
@@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
}
let (fg, bg) = grabcut(&img, &mut trimap, 8)?;
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
let low = super::clean_mask(&low, lw, lh, 0.1, 0.0);
// Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open.
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
@@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
let low = super::clean_mask(&low, w, h, 0.1, 0.0);
// As in the rectangle mode: enclosed holes fill only when they look like the object.
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
if !low.iter().any(|v| *v) {
return None;
}
+58
View File
@@ -912,3 +912,61 @@ fn select_subject_dump_pictures() {
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
/// A dark square on a light background with a square hole that shows the background colour.
fn square_with_hole(hole: usize) -> RgbImage {
let (w, h) = (400usize, 300usize);
let mut rng = Rng::new(11);
let (x0, y0) = (100usize, 50usize);
RgbImage::from_fn(w, h, |x, y| {
let n = 0.02 * rng.normal();
let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200;
let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole;
let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
})
}
#[test]
fn object_select_keeps_small_background_hole_open() {
let img = square_with_hole(8);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap();
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected");
}
#[test]
fn object_at_keeps_small_background_hole_open() {
let img = square_with_hole(16);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap();
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected");
}
#[test]
fn hole_fill_without_specks_fills_object_coloured_holes_only() {
// The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does.
let (w, h) = (40usize, 40usize);
let mut rng = Rng::new(5);
let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y);
let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y);
let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y);
let img = RgbImage::from_fn(w, h, |x, y| {
let n = 0.02 * rng.normal();
let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
});
let mask: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect();
// The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour.
let dark: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 3, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05);
assert!(out[25 * w + 25], "a speck of the object's colour fills");
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
}
+60 -8
View File
@@ -69,6 +69,23 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
h / n.log2()
}
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the
/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently.
fn ipow(x: f32, k: u32) -> f32 {
let (mut base, mut k, mut acc) = (x, k, 1.0f32);
loop {
if k & 1 != 0 {
acc *= base;
}
k >>= 1;
if k == 0 {
return acc;
}
base *= base;
}
}
fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
if v.is_empty() {
@@ -97,7 +114,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
_ => {}
}
let mean = v.iter().sum::<f32>() / n;
let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::<f32>() / n;
let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::<f32>() / n;
match s {
Stat::Mean => mean,
Stat::Variance => m(2),
@@ -105,7 +122,7 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
Stat::Skewness => {
let sd = m(2).sqrt();
// Map −2..2 to 0..1 (0.5 = symmetric).
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) }
if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) }
}
Stat::Kurtosis => {
let var = m(2);
@@ -124,6 +141,27 @@ const CHUNK: usize = 4096;
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
// Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so
// the compiler builds one loop per statistic with the `match` folded away. Matching on `s`
// three times per pixel inside one shared loop made one thread 5–15% slower than the old
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
// instructions by a fifth.
match s {
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)),
Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)),
Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)),
Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)),
Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)),
Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)),
Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)),
}
}
fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
// Every output pixel depends only on the input pixels at the same index, so chunks of the
@@ -133,22 +171,24 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| {
// Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused
// across pixels, so keep that, and give each task its own set.
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| Vec::with_capacity(layers.len()));
// Sized to the layer count up front and filled by index (`k` samples per pixel): a
// `push` per sample re-checked the capacity and stored the length every time; indexing
// cuts the loop's instructions by another third.
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]);
let mut scratch = EntropyScratch::new();
let base = chunk * CHUNK;
for (j, o) in outs.iter_mut().enumerate() {
let i = base + j;
for v in samples.iter_mut() {
v.clear();
}
let mut k = 0;
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c].push(p[c]);
samples[c][k] = p[c];
}
k += 1;
alpha = alpha.max(p[3]);
}
}
@@ -156,7 +196,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s, &mut scratch);
o[c] = f(&mut samples[c][..k], &mut scratch);
}
o[3] = alpha;
}
@@ -457,4 +497,16 @@ mod tests {
fn no_layers_gives_an_empty_result() {
assert!(combine(&[], Stat::Median).is_empty());
}
/// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power.
#[test]
fn ipow_squares_and_multiplies() {
for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] {
assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits());
assert_eq!(ipow(x, 1).to_bits(), x.to_bits());
assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits());
assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits());
assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits());
}
}
}
+65
View File
@@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() {
assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 });
}
#[test]
fn nonpositive_tile_sizes_match_one_pixel_tiles() {
let area = Rect::new(0, 0, 4, 3);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let source = pattern(depth, area);
for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) {
let expected = apply_tiled(&source, &params, area, area, None, 1, Some(area));
for tile in [0, -64, i32::MIN] {
let actual = apply_tiled(&source, &params, area, area, None, tile, Some(area));
assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
let cancellable = apply_tiled_with(&source, &params, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE)
.expect("a nonpositive tile size is normalized, not rejected");
assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
assert!(apply_tiled_with(&source, &params, area, area, None, tile, Some(area), &cancelled).is_none());
}
}
assert_eq!(source.read_region(area), pattern(depth, area).read_region(area));
}
}
#[test]
fn every_filter_is_tile_independent() {
let s = pattern(SampleType::U8, R);
@@ -248,6 +269,32 @@ fn minimum_maximum_spread() {
assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6);
}
/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop
/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps
/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones).
#[test]
fn minimum_maximum_treat_cmyk_as_ink() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true));
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]);
s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]);
for preserve in [Preserve::Squareness, Preserve::Roundness] {
let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve });
for x in 17..24 {
assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15));
}
assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha");
let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve });
for x in 17..24 {
let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 };
assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15));
}
assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha");
}
}
}
#[test]
fn maximum_roundness_grows_a_point_into_a_disc() {
// One white pixel on black: Maximum spreads it over the structuring element itself.
@@ -414,6 +461,24 @@ fn twirl_keeps_centre_and_outside() {
assert_ne!(out.pixel(20, 12), s.pixel(20, 12));
}
/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as
/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges
/// on the middle row changes, the corners don't, and a square still uses its inscribed circle.
#[test]
fn round_distortions_fill_the_inscribed_ellipse() {
let b = Rect::new(0, 0, 80, 40);
let s = pattern(SampleType::F32, b);
for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] {
let out = apply(&s, &p, b, b, None);
// 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse.
assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}");
assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}");
for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] {
assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse");
}
}
}
#[test]
fn motion_blur_spreads_along_angle_only() {
let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);
+51
View File
@@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() {
assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens");
}
#[test]
fn lens_flare_never_darkens_32_bit_values_above_one() {
// #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p);
for px in out.read_region(R).as_chunks::<4>().0 {
assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}");
}
}
#[test]
fn lens_flare_8_bit_result_is_the_screen_blend() {
// The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R);
let src = pattern(SampleType::U8, R);
let base = src.read_region(R);
let expected: Vec<f32> = base
.as_chunks::<4>()
.0
.iter()
.zip(amount.as_chunks::<4>().0)
.flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]]))
.collect();
let mut want = Surface::new(fmt(SampleType::U8));
want.write_region(R, &expected);
assert_eq!(run(&src, &p).read_region(R), want.read_region(R));
}
#[test]
fn lighting_point_light_falls_off() {
let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]);
@@ -608,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() {
assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5);
}
/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop
/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched.
/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1).
#[test]
fn custom_offset_brightens_cmyk() {
let mut id = vec![0.0; 25];
id[12] = 1.0;
let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 };
for depth in [SampleType::U8, SampleType::U16] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]);
let w = run(&s, &p).pixel(5, 5);
assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]);
let c = run(&s, &p).pixel(5, 5);
let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0];
assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}");
}
}
#[test]
fn hsb_filter_encodes_and_decodes() {
let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]);
+51
View File
@@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
mask
}
/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100)
/// shift, as a fully matched pixel of that colour becomes.
pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
let mut px = [[color[0], color[1], color[2], 1.0]];
replace_color_masked(&mut px, &[1.0], hue, saturation, lightness);
[px[0][0], px[0][1], px[0][2]]
}
/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as
/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has
/// no hue to turn, black or white no saturation to give) goes as far as they do.
pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) {
let [fh, fs, fl] = rgb_to_hsl(from);
let [th, ts, tl] = rgb_to_hsl(to);
// Hue turns the short way round; a grey target keeps the hue where it is.
let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 };
// Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0.
let amount = |from: f32, to: f32| {
if to >= from {
if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 }
} else if from <= 0.0 {
0.0
} else {
(to / from - 1.0) * 100.0
}
};
(hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0))
}
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
@@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
mod tests {
use super::*;
/// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets
/// the sliders that give it.
#[test]
fn replace_color_result_and_shift_round_trip() {
let from = [0.8, 0.2, 0.2];
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change");
for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] {
let (h, s, l) = replace_color_shift(from, to);
assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l));
assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l));
}
// Red to blue turns the short way (−120°, not +240°).
assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5);
// A grey target: saturation all the way down, the hue left alone.
let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]);
assert_eq!((h, s), (0.0, -100.0));
// Black has no saturation to raise: the slider stays put rather than dividing by zero.
let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]);
assert!(h.is_finite() && s.is_finite() && l > 0.0);
}
fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {
+74 -5
View File
@@ -231,14 +231,37 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before
/// admitting a transform: clipping these bounds must not hide an excessive scale.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
corner_bounds(&warp_corners(src_rect, h)?)
}
/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`].
/// Position does not affect the price, so translating a small layer far away stays cheap.
/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse
/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output.
pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
let corners = warp_corners(src_rect, h)?;
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) {
return Some((u64::MAX, u64::MAX));
}
let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor();
let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor();
let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil();
let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil();
// Float-to-integer casts saturate, including when the finite endpoints' difference overflows.
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
Some(
[(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64)),
)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
@@ -310,7 +333,12 @@ fn warp(
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
// sign of the forward w at the source point it samples, and the other side would show as a
// mirrored copy wrapped round infinity (#4863).
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
// A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn
// (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557).
let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64)));
let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0)));
let front =
side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])));
let bounds = corner_bounds(&corners);
let dst = match (clip, bounds) {
// The warped corners only bound the result while the whole source is in front of the horizon.
@@ -510,6 +538,31 @@ fn warp(
mod tests {
use super::*;
#[test]
fn output_size_prices_the_unclipped_extent_independently_of_position() {
let src = Rect::new(0, 0, 24, 16);
for dx in [0.0, 900_000.0, 10_000_000.0] {
let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((26, 18)));
}
for sx in [1e7, -1e7] {
let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18)));
}
}
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]);
let bounds = warp_output_bounds(src, &fractional).unwrap();
assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height()))));
let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX)));
assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None);
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
@@ -665,6 +718,22 @@ mod tests {
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
}
/// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across
/// the layer with most of it (and its centre) on the far side. The clip maps wholly into the
/// quad, so its side is the one drawn, not the source's majority.
#[test]
fn a_clip_wholly_on_one_side_draws_that_side() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]);
let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]];
let h = quad_to_rect(&q, 50.0, 39.0).unwrap();
assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side");
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap();
assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}");
}
}
#[test]
fn identity_and_translation_are_exact() {
let mut s = rgba();
+50 -10
View File
@@ -14,8 +14,9 @@
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Surface, Tile};
use std::sync::Arc;
use crate::transform::{Interp, bicubic};
@@ -207,7 +208,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let n = fmt.channels();
let a = n - 1;
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec<f32>)> {
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
if tris.is_empty() {
return None;
}
@@ -296,6 +297,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c])
}
};
// Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so
// the samplers can index `px` directly instead of bounds-checking every tap.
let base = |x: i32, y: i32, taps: i32| -> Option<usize> {
(x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n)
};
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
@@ -313,6 +319,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
Interp::Bilinear => {
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (fx, fy) = (u - f64::from(ix), v - f64::from(iy));
if let Some(o) = base(ix, iy, 2) {
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
let p = &px[o + (dy * fw + dx) * n..][..n];
for (s, v) in acc.iter_mut().zip(p) {
*s += f64::from(*v) * wx * wy;
}
}
}
return;
}
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
@@ -327,6 +344,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let (u, v) = (snap(u), snap(v));
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
if let Some(o) = base(ix - 1, iy - 1, 4) {
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
if k == 0.0 {
continue;
}
let p = &px[o + (j * fw + i) * n..][..n];
for (s, v) in acc.iter_mut().zip(p) {
*s += f64::from(*v) * k;
}
}
}
return;
}
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
@@ -378,19 +410,27 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
outp[b + a] = al as f32;
}
any.then_some((*t, outp))
if !any {
return None;
}
// Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at
// a time once all are done. `t` lies in one tile.
let mut one = Surface::new(fmt);
one.write_region(*t, &outp);
one.prune();
if one.tile_count() == 0 {
return None;
}
one.take_tiles(*t).pop()
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<f32>)> = {
let done: Vec<(TileCoord, Arc<Tile>)> = {
use rayon::prelude::*;
tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
for (r, v) in done {
out.write_region(r, &v);
}
out.prune();
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
out.put_tiles(done);
out
}
+4
View File
@@ -30,3 +30,7 @@ cap-std = "4"
[dev-dependencies]
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"] }
# Reading back an extended attribute the write path should have preserved (#7095).
[target.'cfg(unix)'.dev-dependencies]
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
+4 -2
View File
@@ -19,7 +19,8 @@ pub struct Opened {
/// Decode a document that was read through a filesystem capability.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
let document = photocraft_format::load_from_bytes(bytes)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let r = photocraft_io::import(name, bytes)?;
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
@@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
if path.is_dir() {
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
let document = photocraft_format::load_path(path)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+89 -83
View File
@@ -72,6 +72,22 @@ impl Headless {
}
}
/// Run `body` with document `index` active (the active one when `None`), then make the
/// previously active document active again, on success and on error alike: an `index`
/// targets one call and must not retarget the next automation command (#7025).
fn on_doc<T>(&mut self, index: Option<usize>, body: impl FnOnce(&mut Self) -> Result<T, AutomationError>) -> Result<T, AutomationError> {
let i = self.doc_index(index)?;
let previously_active = self.session.active_index();
if previously_active != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let result = body(self);
if let Some(active) = previously_active.filter(|&active| self.session.active_index() != Some(active)) {
self.session.set_active(active);
}
result
}
/// Open a file and make it the active document.
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
@@ -113,27 +129,25 @@ impl Headless {
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
return self.command_run("file.placeEmbedded", Value::Object(opts));
}
let bytes = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
self.on_doc(index, |this| {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
return this.command_run("file.placeEmbedded", Value::Object(opts));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
self.sync_jobs();
// The bytes are already in memory and the path resolved beneath the root capability, so
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?)
let bytes = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
this.sync_jobs();
// The bytes are already in memory and the path resolved beneath the root capability, so
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
Ok(file_cmds::place_bytes(&mut this.session, name, bytes, None, &Value::Object(opts))?)
})
}
/// Export each exported layer of a document as its own file beneath the write root: the
@@ -143,25 +157,23 @@ impl Headless {
/// already exists, where the ambient command would, and it creates the directory before the
/// export runs, so a refused export can leave an empty directory behind — never a file.
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.layersToFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
self.on_doc(index, |this| {
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return this.command_run("file.export.layersToFiles", Value::Object(opts));
}
this.sync_jobs();
let workspace = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
})
}
/// Export each data set of a document as its own flattened file beneath the write root: the
@@ -169,25 +181,23 @@ impl Headless {
/// batch is planned first and colliding names are refused before any file is written; the
/// directory itself is created before that, so a refused batch can leave it empty behind.
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
self.on_doc(index, |this| {
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return this.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
this.sync_jobs();
let workspace = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
})
}
/// Import a data set CSV from the read root: the capability-scoped
@@ -195,24 +205,22 @@ impl Headless {
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
/// handed to the command's own parser over the text it already has.
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return self.command_run("file.import.variableDataSets", Value::Object(opts));
}
let text = match &self.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
self.on_doc(index, |this| {
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return this.command_run("file.import.variableDataSets", Value::Object(opts));
}
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
};
self.sync_jobs();
Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?)
let text = match &this.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
}
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
};
this.sync_jobs();
Ok(photocraft_engine::import_data_sets_text(&mut this.session, &text, params)?)
})
}
/// Save (`.pcraft` or any export format by extension). With no path,
@@ -266,14 +274,12 @@ impl Headless {
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) {
// Saving by index must not retarget the next automation command.
let previously_active = self.session.active_index();
self.session.set_active(i);
if let Some(st) = self.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
if let Some(active) = previously_active {
self.session.set_active(active);
}
self.on_doc(Some(i), |this| {
if let Some(st) = this.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
Ok(())
})?;
}
Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings }))
}
+71 -4
View File
@@ -114,10 +114,20 @@ impl Headless {
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
opts.encode.webp_lossless = false;
opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default();
match p.get("quality") {
None | Some(Value::Null) => {}
Some(v) => {
// Like the CLI's `--quality`: never ignore or clamp a bad value.
let q = v
.as_u64()
.filter(|q| (1..=100).contains(q))
.and_then(|q| u8::try_from(q).ok())
.ok_or_else(|| bad(format!("`quality` must be a whole number from 1 to 100, got {v}")))?;
opts.encode.jpeg_quality = q;
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
}
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
@@ -576,6 +586,26 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// Regression: `quality` 85.0 or "85" was ignored (a lossless WebP, reply ok) and 0 or 500 was
/// clamped, where the CLI refuses anything but a whole number from 1 to 100.
#[test]
fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
let dir = std::env::temp_dir().join(format!("pc-rpc-save-quality-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("x.webp");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 16, "height": 16})).unwrap();
for q in [json!(85.0), json!("85"), json!(0), json!(500), json!(-5), json!(true)] {
let err = h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": q})).unwrap_err().to_string();
assert!(err.contains("quality"), "{q}: {err}");
assert!(!path.exists(), "{q} wrote a file");
}
h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
let mut h = Headless::new();
@@ -756,6 +786,43 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn indexed_place_and_data_set_methods_keep_the_active_document() {
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap();
std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "first"})).unwrap();
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap();
let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id");
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.unwrap();
h.handle("doc.select", json!({"index": 0})).unwrap();
let steps = [
("doc.place", json!({"path": "logo.png", "index": 1}), true),
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
// A failing call restores the active document too.
("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false),
];
for (method, params, succeeds) in steps {
let result = h.handle(method, params.clone());
assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}");
assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1");
}
assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document");
assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1");
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
+54 -4
View File
@@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> {
}
/// Compare fixed-width tokens without an early exit on a mismatching byte.
///
/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps
/// the token lowercased, so a client sending the token exactly as it was configured must still
/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit.
pub fn token_matches(expected: &str, supplied: &str) -> bool {
if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN {
return false;
}
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0
}
/// Validate the first TCP frame without exposing any control method before authentication.
@@ -368,6 +372,45 @@ mod tests {
assert_eq!(reply["result"]["authenticated"], true);
}
fn auth_line(token: &str) -> String {
json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
}
/// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a
/// client sending the token exactly as it was configured was refused.
#[test]
fn a_token_authenticates_in_any_letter_case() {
let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef";
let expected = server_token(Some(configured), None).unwrap();
for supplied in [configured, &configured.to_ascii_lowercase(), mixed] {
let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected);
assert!(authenticated, "{supplied}: {reply}");
assert_eq!(reply["result"]["authenticated"], true);
}
// The comparison is case-insensitive in both directions.
assert!(token_matches(configured, &configured.to_ascii_lowercase()));
// A different token, or a non-hex character that only folds to a hex one, still fails.
let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
let (_, authenticated) = authentication_reply(&auth_line(other), &expected);
assert!(!authenticated);
let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
assert!(!token_matches(&expected, not_hex));
}
/// #6425: the same for a token file whose contents are uppercase.
#[test]
fn an_uppercase_token_file_authenticates_its_exact_contents() {
let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap()));
let contents = generate_token().unwrap().to_ascii_uppercase();
create_token_file(&path, &contents).unwrap();
let expected = server_token(None, Some(&path));
std::fs::remove_file(&path).unwrap();
let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap());
assert!(authenticated, "{reply}");
}
#[test]
fn repeat_auth_on_an_authenticated_connection_says_so() {
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
@@ -437,16 +480,23 @@ mod tests {
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
// The test thread writes the first line before the deadline starts. The sender writes the
// second line once the connection has authenticated and the deadline has passed.
client.write_all(b"one\n").unwrap();
let limit = Duration::from_secs(1);
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit));
let deadline = Instant::now() + limit;
let (tx, rx) = std::sync::mpsc::channel();
let sender = std::thread::spawn(move || {
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
rx.recv().unwrap();
std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now()));
client.write_all(b"two\n").unwrap();
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
tx.send(()).unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
+25 -3
View File
@@ -170,6 +170,13 @@ pub struct SaveParams {
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option<u8>,
/// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or
/// "bc3" (DXT5).
#[serde(default, rename = "ddsCompression")]
pub dds_compression: Option<String>,
/// DDS: also write the full mip chain, each level a box filter of the one above.
#[serde(default, rename = "ddsMipmaps")]
pub dds_mipmaps: bool,
#[serde(default)]
pub index: Option<usize>,
}
@@ -1001,10 +1008,18 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) {
return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}")));
}
let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) {
None => None,
Some((_, Some(c))) => Some(c),
Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))),
};
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
// does) and the Targa bits, so the other options are headless-only. Saying so beats
// does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats
// saving with defaults while the caller believes their quality or format was applied.
let unsupported: Vec<&str> = [
p.format.is_some().then_some("format"),
@@ -1026,15 +1041,22 @@ impl PhotocraftMcp {
if let Some(bits) = tga_bits {
params["tgaBits"] = json!(bits.bits());
}
if let Some(c) = dds_compression {
params["ddsCompression"] = json!(c.name());
}
if p.dds_mipmaps {
params["ddsMipmaps"] = json!(true);
}
return to_result(b.call("app.save", params).await);
}
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps };
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
opts.encode.jpeg_quality = q;
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
let path = p.path.map(PathBuf::from);
+45 -2
View File
@@ -74,6 +74,13 @@ impl AuthorizedWorkspace {
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
/// the previous file is untouched and the temporary file is removed.
///
/// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` —
/// onto the file that takes its place (#7095), through the two open descriptors rather than
/// their paths, so nothing here reaches outside the write root to finish a write. The rest
/// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission
/// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this
/// path may not use.
///
/// The parent directory must already exist. `cap-std` performs path
/// resolution and file creation relative to the held directory handle, so
/// a non-existent final target is supported without ambient path access.
@@ -85,10 +92,20 @@ impl AuthorizedWorkspace {
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
let written = root.dir.open_with(&tmp, &options).and_then(|file| {
let mut file = file.into_std();
file.write_all(bytes)?;
#[cfg(unix)]
{
use std::os::fd::AsFd;
// Best effort, and only when there is a file being replaced: a save still
// succeeds for a document whose Finder tag could not come along.
if let Ok(old) = root.dir.open(&relative) {
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
}
}
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
photocraft_format::atomic::sync_file(&file.into_std())
photocraft_format::atomic::sync_file(&file)
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
if let Err(e) = renamed {
@@ -572,6 +589,32 @@ mod tests {
assert!(temp_files(&inside).is_empty());
}
/// #7095: replacing a file through the automation path keeps the old file's extended
/// attributes, the same as a save through `atomic_write` does.
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn write_keeps_the_replaced_files_extended_attributes() {
#[cfg(target_os = "macos")]
const TAG: &str = "com.apple.metadata:_kMDItemUserTags";
#[cfg(not(target_os = "macos"))]
const TAG: &str = "user.photocraft.test";
let (inside, _, workspace) = roots("xattr");
let path = inside.join("doc.psd");
workspace.write("doc.psd", b"original").unwrap();
if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() {
return; // a file system without extended attributes: nothing to assert
}
workspace.write("doc.psd", b"replacement, longer").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer");
let mut buf = [0u8; 64];
let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped");
assert_eq!(&buf[..len], b"tagged");
assert!(temp_files(&inside).is_empty());
}
#[cfg(unix)]
#[test]
fn write_into_read_only_folder_keeps_the_original() {
+4 -3
View File
@@ -4,14 +4,15 @@
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile
/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes.
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
for _ in 0..n {
out.extend(bgra);
}
out
}
+43
View File
@@ -318,6 +318,10 @@ async fn errors_are_tool_errors_not_crashes() {
assert!(text(&r).contains("bridge"));
let r = call(&client, "doc_export", json!({})).await;
assert_eq!(r.is_error, Some(true));
// Quality 0 is refused, not clamped to 1 (serde already refuses 500, -1 and "85").
let r = call(&client, "doc_save", json!({"path": "q.jpg", "quality": 0})).await;
assert_eq!(r.is_error, Some(true));
assert!(text(&r).contains("from 1 to 100"), "{}", text(&r));
// Server still alive.
json_of(&call(&client, "session_list", json!({})).await);
client.cancel().await.unwrap();
@@ -415,6 +419,45 @@ async fn headless_doc_save_writes_targa_bits() {
cleanup(&dir);
}
/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown
/// compression is refused before anything is sent.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_doc_save_forwards_dds_options() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await);
assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}}));
let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(text(&r).contains("ddsCompression"), "{}", text(&r));
client.cancel().await.unwrap();
app.abort();
}
/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip
/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps.
#[tokio::test(flavor = "multi_thread")]
async fn headless_doc_save_writes_dds_options() {
let dir = tmp("dds-options");
let client = connect(headless_in(&dir)).await;
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
for (tool, params, fourcc, mips) in [
("doc_export", json!({"path": "plain.dds"}), [0; 4], 1),
("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1),
("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5),
] {
let r = call(&client, tool, params.clone()).await;
assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
assert_eq!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}");
}
let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(!dir.join("bad.dds").exists());
client.cancel().await.unwrap();
cleanup(&dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
+7
View File
@@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
display shaders. Float tables retain near-black detail; unsupported chains return `None`
for the 3D LUT fallback. No GPU dependency is required by this crate.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
@@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
(native only).
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
+131
View File
@@ -0,0 +1,131 @@
//! Separable RGB display transforms without a coarse 3D interpolation near black.
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
/// transform. Values are floats independent of the document's pixel depth.
///
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
/// [`Self::matrix_rows`] to that result, then sample the output table at
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
/// separately between its two adjacent table entries. These nonuniform output samples
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
///
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
/// [`crate::Lut3d`] fallback.
#[derive(Clone, Debug, PartialEq)]
pub struct GpuMatrixShaper {
matrix: [[f32; 4]; 3],
input: Vec<[f32; 4]>,
output: Vec<[f32; 4]>,
}
impl GpuMatrixShaper {
/// The input table has one more entry than this number of equal intervals.
pub const INPUT_INTERVALS: usize = 4096;
/// Output entries are spaced uniformly in the fourth root of the linear value.
pub const OUTPUT_ENTRIES: usize = 4096;
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
self.matrix
}
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
pub fn input_table(&self) -> &[[f32; 4]] {
&self.input
}
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
pub fn output_table(&self) -> &[[f32; 4]] {
&self.output
}
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
if !matrix.iter().flatten().all(|v| v.is_finite()) {
return None;
}
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
for row in matrix {
let magnitude =
row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>() + f64::from(row[3]).abs();
if magnitude > f64::from(f32::MAX) {
return None;
}
}
Some(Self { matrix, input, output })
}
}
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
if let Some(tables) = tables
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
{
return None;
}
let mut packed: Vec<[f32; 4]> = Vec::new();
// Both lengths are fixed constants, but a failed allocation still means the caller
// should retain its existing fallback rather than abort while opening a document.
packed.try_reserve_exact(entries).ok()?;
for i in 0..entries {
let rgb = match tables {
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
None => [identity(i as f32 / (entries - 1) as f32); 3],
};
if !rgb.iter().all(|v| v.is_finite()) {
return None;
}
if let Some(previous) = packed.last()
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
{
return None;
}
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
}
Some(packed)
}
#[cfg(test)]
mod tests {
use super::*;
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
#[test]
fn rejects_invalid_tables_and_nonfinite_values() {
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
let mut invalid = inputs.clone();
invalid[0].pop();
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut invalid = inputs.clone();
invalid[1][123] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
let mut invalid = outputs.clone();
invalid[2][321] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
let mut invalid = IDENTITY;
invalid[1][3] = v;
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
}
}
#[test]
fn rejects_finite_data_that_overflows_shader_arithmetic() {
let mut matrix = IDENTITY;
matrix[0] = [f32::MAX; 4];
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
inputs[0][100] = -f32::MAX;
inputs[0][101] = f32::MAX;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
}
}
+3
View File
@@ -8,6 +8,7 @@
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
//!
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
//! CIE 15, and Adobe's published black point compensation paper. This crate depends on no
@@ -19,6 +20,7 @@ pub mod builtin;
pub mod clut;
pub mod curve;
pub mod gamut;
mod gpu_matrix_shaper;
pub mod lut3d;
pub mod lutfile;
pub mod math;
@@ -32,6 +34,7 @@ pub use builtin::Builtin;
pub use clut::Clut;
pub use curve::Curve;
pub use gamut::GamutCheck;
pub use gpu_matrix_shaper::GpuMatrixShaper;
pub use lut3d::Lut3d;
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
+28 -3
View File
@@ -9,7 +9,7 @@ use crate::curve::Curve;
use crate::math;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent};
use crate::{CmsError, GpuMatrixShaper, Intent};
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,9 +39,9 @@ impl Default for TransformOptions {
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
pub const GRID_3D: usize = 33;
pub const GRID_4D: usize = 17;
const TABLE_1D: usize = 4096;
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
const OUT_TABLE: usize = 4096;
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
@@ -226,6 +226,31 @@ impl Transform {
&self.pipeline
}
/// A separable RGB transform for accurate GPU display conversion, when available.
///
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
/// can keep their existing display-LUT fallback. This is an SDR representation; see
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
if self.inputs != 3 || self.outputs != 3 {
return None;
}
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
if let Some((nr, nc, coefficients, offsets)) = matrix {
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
return None;
}
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
let [r, g, b] = coefficients;
*row = [*r, *g, *b, *offset];
}
}
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
}
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
+197
View File
@@ -0,0 +1,197 @@
//! Independent ICC matrix/TRC reference for display shadows.
//!
//! Fixtures are generated here from the published sRGB equation and a synthetic monitor,
//! with no photograph or device profile. The expected values implement ICC.1:2010 Annex F.3
//! directly in f64, without Transform, Curve evaluation, or photocraft_cms::math:
//! https://www.color.org/specification/ICC1v43_2010-12.pdf
use photocraft_cms::pipeline::{Pipeline, Stage};
use photocraft_cms::{Builtin, Clut, Curve, GpuMatrixShaper, Intent, Lut3d, Profile, Transform, TransformOptions};
const SOURCE_MATRIX: [[f64; 3]; 3] = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
// Deliberately synthetic, D50-referenced wider-gamut monitor; not a user's measured profile.
const MONITOR_MATRIX: [[f64; 3]; 3] = [[0.55, 0.27, 0.1442], [0.25, 0.69, 0.06], [0.01, 0.06, 0.7549]];
const GAMMA: f64 = 2.2;
const SOURCE_ENTRIES: usize = 1024;
const DISPLAY_TOLERANCE: f64 = 1.0 / 255.0;
fn fixed16(x: f64) -> f64 {
(x * 65536.0).round() / 65536.0
}
fn srgb_sample(i: usize) -> f64 {
let x = i as f64 / (SOURCE_ENTRIES - 1) as f64;
let linear = if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
(linear * 65535.0).round() / 65535.0
}
fn synthetic_profiles() -> (Profile, Profile) {
let mut source = Builtin::Srgb.profile().clone();
source.description = "Synthetic sampled sRGB for display regression".into();
source.matrix = Some(SOURCE_MATRIX);
source.trc = Some(std::array::from_fn(|_| Curve::Table((0..SOURCE_ENTRIES).map(|i| srgb_sample(i) as f32).collect())));
let source = source.with_encoded_bytes();
let mut monitor = Builtin::Srgb.profile().clone();
monitor.description = "Synthetic gamma 2.2 display for regression".into();
monitor.matrix = Some(MONITOR_MATRIX);
monitor.trc = Some(std::array::from_fn(|_| Curve::Parametric { kind: 0, p: [GAMMA, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }));
let monitor = monitor.with_encoded_bytes();
// Exercise actual ICC serialization/parsing, including fixed-point matrix/TRC values.
(Profile::parse(&source.to_bytes()).unwrap(), Profile::parse(&monitor.to_bytes()).unwrap())
}
fn synthetic_transform(bpc: bool) -> Transform {
let (source, monitor) = synthetic_profiles();
Transform::new(&source, &monitor, Intent::RelativeColorimetric, bpc).unwrap()
}
fn reference(rgb: [f32; 3]) -> [f64; 3] {
let linear = rgb.map(|x| {
let p = f64::from(x.clamp(0.0, 1.0)) * (SOURCE_ENTRIES - 1) as f64;
let i = (p as usize).min(SOURCE_ENTRIES - 2);
srgb_sample(i) + (srgb_sample(i + 1) - srgb_sample(i)) * (p - i as f64)
});
let xyz = SOURCE_MATRIX.map(|row| row.iter().zip(linear).map(|(m, x)| fixed16(*m) * x).sum::<f64>());
// Solve M_monitor * linear_monitor = XYZ using elimination, rather than the production
// matrix inverse/multiplication implementation.
let mut a: [[f64; 4]; 3] = std::array::from_fn(|r| [fixed16(MONITOR_MATRIX[r][0]), fixed16(MONITOR_MATRIX[r][1]), fixed16(MONITOR_MATRIX[r][2]), xyz[r]]);
for pivot in 0..3 {
let divisor = a[pivot][pivot];
for value in &mut a[pivot][pivot..] {
*value /= divisor;
}
let normalized = a[pivot];
for (r, row) in a.iter_mut().enumerate() {
if r != pivot {
let factor = row[pivot];
for (value, coefficient) in row.iter_mut().zip(normalized).skip(pivot) {
*value -= factor * coefficient;
}
}
}
}
std::array::from_fn(|c| a[c][3].clamp(0.0, 1.0).powf(1.0 / fixed16(GAMMA)))
}
/// CPU interpretation of the public GPU data layout; expected colours come only from
/// `reference`, so this tests the sampled representation, not Transform against itself.
fn sample_gpu(gpu: &GpuMatrixShaper, rgb: [f32; 3]) -> [f32; 3] {
fn interpolate(table: &[[f32; 4]], channel: usize, coordinate: f32) -> f32 {
let i = (coordinate as usize).min(table.len() - 2);
let f = coordinate - i as f32;
table[i][channel] + (table[i + 1][channel] - table[i][channel]) * f
}
let linear: [f32; 3] = std::array::from_fn(|c| interpolate(gpu.input_table(), c, rgb[c].clamp(0.0, 1.0) * GpuMatrixShaper::INPUT_INTERVALS as f32));
let rows = gpu.matrix_rows();
std::array::from_fn(|c| {
let row = rows[c];
let value = row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2] + row[3];
let coordinate = value.clamp(0.0, 1.0).sqrt().sqrt() * (GpuMatrixShaper::OUTPUT_ENTRIES - 1) as f32;
interpolate(gpu.output_table(), c, coordinate).clamp(0.0, 1.0)
})
}
fn check_reference(gpu: &GpuMatrixShaper, rgb: [f32; 3]) {
let actual = sample_gpu(gpu, rgb);
let expected = reference(rgb);
for c in 0..3 {
assert!((f64::from(actual[c]) - expected[c]).abs() <= DISPLAY_TOLERANCE, "input {rgb:?}, channel {c}: GPU {actual:?}, ICC {expected:?}");
}
}
#[test]
fn sampled_srgb_to_gamma_display_preserves_neutral_and_colored_shadows() {
for bpc in [false, true] {
let transform = synthetic_transform(bpc);
let gpu = transform.gpu_matrix_shaper().unwrap();
assert_eq!(gpu.input_table().len(), GpuMatrixShaper::INPUT_INTERVALS + 1);
assert_eq!(gpu.output_table().len(), GpuMatrixShaper::OUTPUT_ENTRIES);
assert!(gpu.input_table().iter().chain(gpu.output_table()).all(|v| v[3] == 1.0));
for v in 0..=255 {
check_reference(&gpu, [v as f32 / 255.0; 3]);
}
// Includes coloured shadows within the old LUT's very first 0..8/255 cell.
for r in 0..=16 {
for g in 0..=16 {
for b in 0..=16 {
check_reference(&gpu, [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
}
}
}
for rgb in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.9, 0.6, 0.3]] {
check_reference(&gpu, rgb);
}
}
}
#[test]
fn coarse_final_rgb_lut_fails_the_same_icc_shadow_reference() {
let transform = synthetic_transform(false);
let gpu = transform.gpu_matrix_shaper().unwrap();
let coarse = Lut3d::from_transform(&transform, 33);
let rgb = [2.0 / 255.0; 3];
let expected = reference(rgb);
let old = coarse.sample(rgb);
// Even float LUT nodes fail, before the additional rounding of RGBA8 upload. This
// guards the cause of the original bug rather than a particular observed screenshot.
assert!(old.iter().zip(expected).all(|(a, e)| e - f64::from(*a) > 2.0 / 255.0), "old {old:?}, reference {expected:?}");
check_reference(&gpu, rgb);
}
#[test]
fn gpu_display_representation_accepts_u8_u16_and_float_samples() {
let transform = synthetic_transform(false);
let gpu = transform.gpu_matrix_shaper().unwrap();
for v in [0_u8, 1, 2, 7, 8, 16, 32, 128, 255] {
check_reference(&gpu, [v as f32 / 255.0; 3]);
}
for v in [0_u16, 1, 2, 15, 63, 257, 514, 1028, 4096, 32768, 65535] {
check_reference(&gpu, [v as f32 / 65535.0; 3]);
}
for rgb in [[0.000003, 0.00001, 0.00002], [0.00123, 0.00456, 0.00789], [0.2, 0.45, 0.8], [-0.5, 0.0, 2.0]] {
check_reference(&gpu, rgb);
}
assert_eq!(sample_gpu(&gpu, [0.0; 3]), [0.0; 3]);
// White differs very slightly from 1 because the synthetic matrices use ICC s15Fixed16.
check_reference(&gpu, [1.0; 3]);
}
#[test]
fn absent_curves_and_matrix_use_identity_without_losing_offsets() {
let identity = Transform::from_pipeline(Pipeline::new(3, Vec::new()), TransformOptions::default()).gpu_matrix_shaper().unwrap();
for rgb in [[0.0; 3], [1.0; 3], [0.001, 0.2, 0.8], [-2.0, 0.5, 2.0]] {
for (actual, input) in sample_gpu(&identity, rgb).into_iter().zip(rgb) {
assert!((actual - input.clamp(0.0, 1.0)).abs() < 1e-6);
}
}
let matrix = Stage::Matrix { rows: 3, cols: 3, m: vec![0.8, 0.0, 0.0, 0.0, 0.7, 0.0, 0.0, 0.0, 0.6], offset: vec![0.01, 0.02, 0.03] };
let transform = Transform::from_pipeline(Pipeline::new(3, vec![matrix]), TransformOptions::default()).gpu_matrix_shaper().unwrap();
let input = [0.1, 0.2, 0.3];
let output = sample_gpu(&transform, input);
for (actual, expected) in output.into_iter().zip([0.09, 0.16, 0.21]) {
assert!((actual - expected).abs() < 1e-6, "{output:?}");
}
}
#[test]
fn unsupported_clut_proof_and_non_rgb_transforms_retain_fallback() {
let identity_clut = Clut::sample(vec![2; 3], 3, |input, output| output.copy_from_slice(input));
let clut = Transform::from_pipeline(Pipeline::new(3, vec![Stage::Clut(identity_clut)]), TransformOptions::default());
assert!(clut.gpu_matrix_shaper().is_none());
let srgb = Builtin::Srgb.profile();
let lab = Transform::new(Builtin::LabD50.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
assert!(lab.gpu_matrix_shaper().is_none());
let gray = Transform::new(Builtin::GrayGamma22.profile(), srgb, Intent::RelativeColorimetric, false).unwrap();
assert!(gray.gpu_matrix_shaper().is_none());
let proof = Transform::proof(srgb, Builtin::DisplayP3.profile(), srgb, Intent::RelativeColorimetric, false, false).unwrap();
assert!(proof.gpu_matrix_shaper().is_none());
}
#[test]
fn malformed_matrix_storage_is_not_exposed_to_the_gpu() {
for (coefficients, offsets) in [(vec![2.0; 8], vec![0.0; 3]), (vec![2.0; 9], vec![0.0; 2]), (vec![f64::INFINITY; 9], vec![0.0; 3])] {
let transform =
Transform::from_pipeline(Pipeline::new(3, vec![Stage::Matrix { rows: 3, cols: 3, m: coefficients, offset: offsets }]), TransformOptions::default());
assert!(transform.gpu_matrix_shaper().is_none());
}
}
+10 -2
View File
@@ -40,7 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| DDS | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | yes (BC1–BC3, when `EncodeOptions::dds` asks for one) | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
@@ -73,7 +73,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing (`EncodeOptions::dds`) makes
a 2D texture with the legacy header: uncompressed by default (BGRA, BGRX, or luminance with or
without alpha, so it reads back exactly), or BC1/BC2/BC3. The block encoder is our own, from
Microsoft's public block-compression documentation: RGB565 endpoints fitted along each block's
principal axis and refined by least squares, indices picked against the palette the reader
decodes; BC1 turns texels under half opacity into its transparent black, and BC3 picks
whichever alpha mode fits. `mipmaps` adds the full chain down to 1×1, each level a box filter
of the one above (the last column and row of an odd-sized level average three pixels, so none
is dropped). Cubemaps, arrays, BC4–BC7 and sRGB (DX10-only) formats aren't written.
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
+276 -35
View File
@@ -1,13 +1,24 @@
//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
//! texture arrays and volume textures are refused rather than flattened to one face or slice.
//! DirectDraw Surface (DDS) textures, after Microsoft's public DDS documentation ("DDS",
//! "DDS_HEADER", "DDS_PIXELFORMAT", "DDS_HEADER_DXT10").
//!
//! Reading: the top mip level of a 2D texture, either uncompressed (8 to 32 bits per pixel
//! described by channel masks, RGB or luminance) or block-compressed BC1/BC2/BC3
//! (DXT1/DXT3/DXT5), with or without the DX10 header. Cubemaps, texture arrays and volume
//! textures are refused rather than flattened to one face or slice.
//!
//! Writing: a 2D texture, uncompressed (BGRA or BGRX, luminance with or without alpha) or
//! BC1/BC2/BC3, optionally with its full mip chain. The legacy header describes all of these,
//! so no DX10 header is written; formats only DXGI can name (BC4–BC7, sRGB) aren't written.
use std::borrow::Cow;
use super::dds_bc::{self, Block, Mask};
use crate::SampleType;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
use crate::options::{DdsCompression, EncodeOptions, Limits};
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
@@ -224,27 +235,12 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
Image::from_u8(width, height, layout, pixels)
}
fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
/// `c0 <= c1` order selects three colours and transparent black.
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
let palette = if c0 > c1 || !one_bit_alpha {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
};
let palette = dds_bc::colour_palette(c0, c1, c0 > c1 || !one_bit_alpha);
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
@@ -259,21 +255,9 @@ fn bc2_alpha(block: &[u8]) -> [u8; 16] {
/// 0 and 255 when `a0 <= a1`.
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
let palette = dds_bc::alpha_palette(byte(0), byte(1));
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize])
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
@@ -282,3 +266,260 @@ fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
}
texels
}
const DDSD_CAPS: u32 = 0x1;
const DDSD_HEIGHT: u32 = 0x2;
const DDSD_WIDTH: u32 = 0x4;
const DDSD_PITCH: u32 = 0x8;
const DDSD_PIXELFORMAT: u32 = 0x1000;
const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
const DDSD_LINEARSIZE: u32 = 0x8_0000;
const DDSCAPS_COMPLEX: u32 = 0x8;
const DDSCAPS_TEXTURE: u32 = 0x1000;
const DDSCAPS_MIPMAP: u32 = 0x40_0000;
fn encode_err(reason: impl std::fmt::Display) -> CodecError {
CodecError::encode(Format::Dds, reason)
}
/// How a written texture stores its pixels.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Store {
/// 8-bit luminance.
Luminance,
/// 8-bit luminance, then 8-bit alpha.
LuminanceAlpha,
/// Blue, green, red and an unused byte (written as 255, so a reader that takes it for
/// alpha still sees an opaque texture).
Bgrx,
Bgra,
Bc1,
Bc2,
Bc3,
}
impl Store {
/// The storage for an image of `layout`, as [`crate::fidelity::plan`] chose it.
fn new(compression: DdsCompression, layout: ChannelLayout) -> Result<Store, CodecError> {
Ok(match (compression, layout) {
(DdsCompression::None, ChannelLayout::Gray) => Store::Luminance,
(DdsCompression::None, ChannelLayout::GrayA) => Store::LuminanceAlpha,
(DdsCompression::None, ChannelLayout::Rgb) => Store::Bgrx,
(DdsCompression::None, ChannelLayout::Rgba) => Store::Bgra,
(DdsCompression::Bc1, ChannelLayout::Rgba) => Store::Bc1,
(DdsCompression::Bc2, ChannelLayout::Rgba) => Store::Bc2,
(DdsCompression::Bc3, ChannelLayout::Rgba) => Store::Bc3,
(_, layout) => return Err(encode_err(format!("{layout:?} pixels can't be written as `{}`", compression.name()))),
})
}
/// Bytes per pixel, or per 4×4 block for a block compression.
fn unit(self) -> usize {
match self {
Store::Luminance => 1,
Store::LuminanceAlpha => 2,
Store::Bgrx | Store::Bgra => 4,
Store::Bc1 => 8,
Store::Bc2 | Store::Bc3 => 16,
}
}
fn is_block(self) -> bool {
matches!(self, Store::Bc1 | Store::Bc2 | Store::Bc3)
}
/// Bytes of one row of pixels (or of 4×4 blocks) of a level `width` wide.
fn row_bytes(self, width: usize) -> Option<usize> {
if self.is_block() { width.div_ceil(4).checked_mul(self.unit()) } else { width.checked_mul(self.unit()) }
}
fn level_bytes(self, width: usize, height: usize) -> Option<usize> {
let rows = if self.is_block() { height.div_ceil(4) } else { height };
self.row_bytes(width)?.checked_mul(rows)
}
/// DDS_PIXELFORMAT: flags, FourCC, bits per pixel and the red, green, blue and alpha masks.
fn pixel_format(self) -> (u32, [u8; 4], u32, [u32; 4]) {
match self {
Store::Luminance => (DDPF_LUMINANCE, [0; 4], 8, [0xff, 0, 0, 0]),
Store::LuminanceAlpha => (DDPF_LUMINANCE | DDPF_ALPHAPIXELS, [0; 4], 16, [0xff, 0, 0, 0xff00]),
Store::Bgrx => (DDPF_RGB, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0]),
Store::Bgra => (DDPF_RGB | DDPF_ALPHAPIXELS, [0; 4], 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
Store::Bc1 => (DDPF_FOURCC, *b"DXT1", 0, [0; 4]),
Store::Bc2 => (DDPF_FOURCC, *b"DXT3", 0, [0; 4]),
Store::Bc3 => (DDPF_FOURCC, *b"DXT5", 0, [0; 4]),
}
}
}
/// Levels in a full mip chain: the larger side halves down to 1.
fn mip_levels(width: u32, height: u32) -> u32 {
32 - width.max(height).max(1).leading_zeros()
}
fn half((w, h): (usize, usize)) -> (usize, usize) {
((w / 2).max(1), (h / 2).max(1))
}
pub(crate) fn encode(image: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = image.converted(plan.layout, SampleType::U8);
let pixels = img.as_u8().ok_or_else(|| encode_err("the image was not converted to 8 bits per channel"))?;
let store = Store::new(opts.dds.compression, img.layout())?;
let (width, height) = img.dimensions();
let levels = if opts.dds.mipmaps { mip_levels(width, height) } else { 1 };
let top = (width as usize, height as usize);
// Every level's size, checked before anything is allocated.
let mut sizes = Vec::new();
let mut size = top;
for _ in 0..levels {
sizes.push(store.level_bytes(size.0, size.1).ok_or_else(|| encode_err("texture size overflow"))?);
size = half(size);
}
let total = sizes.iter().try_fold(HEADER, |total, &s| total.checked_add(s)).ok_or_else(|| encode_err("texture size overflow"))?;
let header = header(width, height, store, levels, sizes.first().copied().unwrap_or(0))?;
let mut out = crate::options::alloc_zeroed::<u8>(total)?;
out.get_mut(..HEADER).ok_or_else(|| encode_err("output buffer too small"))?.copy_from_slice(&header);
let channels = img.layout().channels();
let mut level = Cow::Borrowed(pixels);
let (mut size, mut at) = (top, HEADER);
for (i, &bytes) in sizes.iter().enumerate() {
let dst = out.get_mut(at..at + bytes).ok_or_else(|| encode_err("output buffer too small"))?;
write_level(store, &level, size, dst)?;
at += bytes;
if i + 1 < sizes.len() {
let next = half(size);
level = Cow::Owned(downsample(&level, size, next, channels)?);
size = next;
}
}
Ok(out)
}
/// The `DDS ` signature and DDS_HEADER. `top` is the top level's size in bytes.
fn header(width: u32, height: u32, store: Store, levels: u32, top: usize) -> Result<[u8; HEADER], CodecError> {
let mut b = [0u8; HEADER];
let mut put = |at: usize, v: u32| {
if let Some(d) = b.get_mut(at..at + 4) {
d.copy_from_slice(&v.to_le_bytes());
}
};
// Uncompressed: the pitch of one row. Compressed: the whole top level (its "linear size").
let (size_flag, pitch_or_size) = if store.is_block() { (DDSD_LINEARSIZE, Some(top)) } else { (DDSD_PITCH, store.row_bytes(width as usize)) };
let pitch_or_size =
pitch_or_size.and_then(|v| u32::try_from(v).ok()).ok_or_else(|| encode_err(format!("a {width}x{height} level is too large for a DDS header")))?;
let mipmapped = levels > 1;
put(4, 124);
put(8, DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT | size_flag | if mipmapped { DDSD_MIPMAPCOUNT } else { 0 });
put(12, height);
put(16, width);
put(20, pitch_or_size);
put(28, levels);
let (flags, fourcc, bits, masks) = store.pixel_format();
put(76, 32);
put(80, flags);
put(84, u32::from_le_bytes(fourcc));
put(88, bits);
for (i, mask) in masks.into_iter().enumerate() {
put(92 + 4 * i, mask);
}
put(108, DDSCAPS_TEXTURE | if mipmapped { DDSCAPS_COMPLEX | DDSCAPS_MIPMAP } else { 0 });
b[..4].copy_from_slice(b"DDS ");
Ok(b)
}
/// Runs `f` on each `len`-byte row of `out` with its index, in parallel on native targets.
fn rows(out: &mut [u8], len: usize, f: impl Fn(usize, &mut [u8]) + Send + Sync) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, row)| f(i, row));
}
#[cfg(target_arch = "wasm32")]
for (i, row) in out.chunks_mut(len.max(1)).enumerate() {
f(i, row);
}
}
/// Writes one level of `width` × `height` pixels (in the image's layout) into `dst`.
fn write_level(store: Store, pixels: &[u8], (width, height): (usize, usize), dst: &mut [u8]) -> Result<(), CodecError> {
let row_bytes = store.row_bytes(width).ok_or_else(|| encode_err("texture size overflow"))?;
match store {
Store::Luminance | Store::LuminanceAlpha => {
// The image's gray (and alpha) samples, in the same order.
let src = pixels.get(..dst.len()).ok_or_else(|| encode_err("the level is smaller than its size"))?;
dst.copy_from_slice(src);
}
Store::Bgrx | Store::Bgra => {
let channels = if store == Store::Bgra { 4 } else { 3 };
rows(dst, row_bytes, |y, row| {
let src = pixels.get(y * width * channels..).unwrap_or_default();
for (d, s) in row.as_chunks_mut::<4>().0.iter_mut().zip(src.chunks_exact(channels)) {
let byte = |i: usize| s.get(i).copied().unwrap_or(255);
*d = [byte(2), byte(1), byte(0), byte(3)];
}
});
}
Store::Bc1 | Store::Bc2 | Store::Bc3 => {
let (texels, _) = pixels.as_chunks::<4>();
rows(dst, row_bytes, |by, row| {
for (bx, d) in row.chunks_exact_mut(store.unit()).enumerate() {
let (block, inside) = gather(texels, (width, height), bx, by);
let encoded: &[u8] = match store {
Store::Bc1 => &dds_bc::encode_bc1(&block, inside),
Store::Bc2 => &dds_bc::encode_bc2(&block, inside),
_ => &dds_bc::encode_bc3(&block, inside),
};
for (d, s) in d.iter_mut().zip(encoded) {
*d = *s;
}
}
});
}
}
Ok(())
}
/// Block (`bx`, `by`)'s texels, and which of them lie inside the image.
fn gather(texels: &[[u8; 4]], (width, height): (usize, usize), bx: usize, by: usize) -> (Block, Mask) {
let mut block = [[0u8; 4]; 16];
let mut inside: Mask = 0;
for (t, texel) in block.iter_mut().enumerate() {
let (x, y) = (bx * 4 + t % 4, by * 4 + t / 4);
if x < width
&& y < height
&& let Some(p) = texels.get(y * width + x)
{
*texel = *p;
inside |= 1 << t;
}
}
(block, inside)
}
/// The next mip level: each pixel the average of the 2×2 pixels it covers, or of 2×3, 3×2 or
/// 3×3 along the last column and row of an odd-sized level, so no pixel is dropped.
fn downsample(src: &[u8], (w, h): (usize, usize), (nw, nh): (usize, usize), channels: usize) -> Result<Vec<u8>, CodecError> {
let len = nw.checked_mul(nh).and_then(|n| n.checked_mul(channels)).ok_or_else(|| encode_err("mip level size overflow"))?;
let mut dst = crate::options::alloc_zeroed::<u8>(len)?;
let span = |i: usize, n: usize, size: usize| 2 * i..=if i + 1 == n { size.saturating_sub(1) } else { 2 * i + 1 };
rows(&mut dst, nw * channels, |y, row| {
let ys = span(y, nh, h);
for (x, px) in row.chunks_exact_mut(channels.max(1)).enumerate() {
let xs = span(x, nw, w);
for (c, v) in px.iter_mut().enumerate() {
let (mut sum, mut n) = (0u32, 0u32);
for sy in ys.clone() {
for sx in xs.clone() {
if let Some(&s) = src.get((sy * w + sx) * channels + c) {
sum += u32::from(s);
n += 1;
}
}
}
*v = ((sum + n / 2) / n.max(1)) as u8;
}
}
});
Ok(dst)
}
+368
View File
@@ -0,0 +1,368 @@
//! BC1, BC2 and BC3 (DXT1, DXT3, DXT5) blocks, after Microsoft's public block compression
//! documentation ("Block Compression (Direct3D 10)" and "Texture Block Compression in Direct3D
//! 11"): the palettes the DDS reader decodes with, and the block encoder the DDS writer uses.
//!
//! The encoder fits a block's colours with two RGB565 endpoints along their principal axis,
//! refines them by least squares, and scores every candidate against the exact palette the
//! reader rebuilds, so the indices it picks are the best ones for what a decoder shows.
/// The 16 texels of a 4×4 block, row by row, as RGBA.
pub(crate) type Block = [[u8; 4]; 16];
/// Bit `t` set for each texel `t` of a block that counts: edge blocks of a texture whose size is
/// not a multiple of four have texels outside the image.
pub(crate) type Mask = u16;
pub(crate) fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// The colours a colour block's indices select. Four-colour mode (BC1 with `c0 > c1`, and every
/// BC2 and BC3 block) adds two colours at thirds; three-colour mode adds the midpoint and
/// transparent black.
pub(crate) fn colour_palette(c0: u16, c1: u16, four: bool) -> [[u8; 4]; 4] {
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
if four {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
}
}
/// A BC3 alpha block's eight values: six interpolated at sevenths when `a0 > a1`, else four
/// interpolated at fifths followed by 0 and 255.
pub(crate) fn alpha_palette(a0: u8, a1: u8) -> [u8; 8] {
let (a0, a1) = (u32::from(a0), u32::from(a1));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
palette.map(|v| v as u8)
}
/// A BC1 block. Texels under half opacity become BC1's transparent black, which needs the
/// three-colour mode; an opaque block takes whichever mode fits its colours better.
pub(crate) fn encode_bc1(block: &Block, inside: Mask) -> [u8; 8] {
let transparent = select(block, inside, |t| t[3] < 128);
colour_block(block, inside & !transparent, transparent, true)
}
/// A BC2 block: four bits of alpha per texel, then a four-colour block.
pub(crate) fn encode_bc2(block: &Block, inside: Mask) -> [u8; 16] {
let bits = block.iter().enumerate().fold(0u64, |bits, (t, texel)| bits | ((u64::from(texel[3]) * 15 + 127) / 255) << (4 * t));
join(bits.to_le_bytes(), colour_block(block, inside, 0, false))
}
/// A BC3 block: an interpolated alpha block, then a four-colour block.
pub(crate) fn encode_bc3(block: &Block, inside: Mask) -> [u8; 16] {
join(alpha_block(block, inside), colour_block(block, inside, 0, false))
}
fn join(a: [u8; 8], b: [u8; 8]) -> [u8; 16] {
std::array::from_fn(|i| if i < 8 { a.get(i) } else { b.get(i - 8) }.copied().unwrap_or(0))
}
/// The texels of `inside` for which `f` holds.
fn select(block: &Block, inside: Mask, f: impl Fn(&[u8; 4]) -> bool) -> Mask {
block.iter().enumerate().filter(|(t, texel)| inside >> t & 1 != 0 && f(texel)).fold(0, |m, (t, _)| m | 1 << t)
}
fn rgb_of(texel: &[u8; 4]) -> [f32; 3] {
[f32::from(texel[0]), f32::from(texel[1]), f32::from(texel[2])]
}
/// RGB565 of a colour, rounding each channel to the nearest level.
fn to565(c: [f32; 3]) -> u16 {
let q = |v: f32, max: f32| (v.clamp(0.0, 255.0) * max / 255.0).round() as u16;
q(c[0], 31.0) << 11 | q(c[1], 63.0) << 5 | q(c[2], 31.0)
}
fn distance(a: &[u8; 4], b: &[u8; 4]) -> u32 {
(0..3).map(|i| u32::from(a[i].abs_diff(b[i])).pow(2)).sum()
}
/// A colour block (endpoints, then 2-bit indices) for the `opaque` texels' colours, with the
/// `transparent` ones on BC1's transparent index. `bc1` allows the three-colour mode; BC2 and
/// BC3 always decode four colours.
fn colour_block(block: &Block, opaque: Mask, transparent: Mask, bc1: bool) -> [u8; 8] {
let colours: Vec<[f32; 3]> = block.iter().enumerate().filter(|(t, _)| opaque >> t & 1 != 0).map(|(_, texel)| rgb_of(texel)).collect();
if colours.is_empty() {
// Nothing visible: every texel on the transparent index (or index 0 of black).
let indices = if transparent != 0 { u32::MAX } else { 0 };
return pack(0, 0, indices);
}
let (lo, hi) = principal_endpoints(&colours);
let mut candidates = vec![(to565(lo), to565(hi))];
let modes: &[bool] = if transparent != 0 {
&[false]
} else if bc1 {
&[true, false]
} else {
&[true]
};
let mut best: Option<(u32, [u8; 8])> = None;
// Each refinement starts from the best block so far: its indices give the least-squares
// endpoints, which become one more candidate.
for round in 0..3 {
for &(e0, e1) in &candidates {
for &four in modes {
let (bytes, error) = fit(block, opaque, transparent, e0, e1, four);
if best.is_none_or(|(e, _)| error < e) {
best = Some((error, bytes));
}
}
}
let Some((error, bytes)) = best else { break };
if error == 0 || round == 2 {
break;
}
candidates = least_squares(block, opaque, &bytes).into_iter().collect();
if candidates.is_empty() {
break;
}
}
best.map_or([0; 8], |(_, bytes)| bytes)
}
/// Orders endpoints `e0`, `e1` for the mode (`c0 > c1` for four colours, `c0 <= c1` for three)
/// and picks each texel's nearest palette colour. Returns the block and its squared error.
fn fit(block: &Block, opaque: Mask, transparent: Mask, e0: u16, e1: u16, four: bool) -> ([u8; 8], u32) {
let (c0, c1) = if four == (e0 >= e1) { (e0, e1) } else { (e1, e0) };
// Equal endpoints can't be in four-colour order; the palette is then one colour either way.
let palette = colour_palette(c0, c1, four && c0 > c1);
let choices = if four && c0 > c1 { 4 } else { 3 };
let mut indices = 0u32;
let mut error = 0u32;
for (t, texel) in block.iter().enumerate() {
let index = if transparent >> t & 1 != 0 {
3
} else if opaque >> t & 1 != 0 {
let (i, d) = palette.iter().take(choices).enumerate().map(|(i, p)| (i, distance(texel, p))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
error += d;
i as u32
} else {
0
};
indices |= index << (2 * t);
}
(pack(c0, c1, indices), error)
}
fn pack(c0: u16, c1: u16, indices: u32) -> [u8; 8] {
let (c0, c1, i) = (c0.to_le_bytes(), c1.to_le_bytes(), indices.to_le_bytes());
[c0[0], c0[1], c1[0], c1[1], i[0], i[1], i[2], i[3]]
}
/// The ends of the colours' spread along their principal axis (the covariance's dominant
/// eigenvector, by power iteration), clamped to the RGB cube.
fn principal_endpoints(colours: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
let n = colours.len().max(1) as f32;
let mean = [0, 1, 2].map(|i| colours.iter().map(|c| c[i]).sum::<f32>() / n);
let mut cov = [[0f32; 3]; 3];
for c in colours {
let d = [c[0] - mean[0], c[1] - mean[1], c[2] - mean[2]];
for (i, row) in cov.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v += d[i] * d[j];
}
}
}
let times = |a: [f32; 3]| [0, 1, 2].map(|i| cov[i][0] * a[0] + cov[i][1] * a[1] + cov[i][2] * a[2]);
let iterate = |start: usize| {
let mut axis = [0f32; 3];
axis[start] = 1.0;
for _ in 0..8 {
let next = times(axis);
let len = next.iter().map(|v| v * v).sum::<f32>().sqrt();
if !len.is_normal() {
break;
}
axis = next.map(|v| v / len);
}
axis
};
// From each channel's axis, keeping the one the colours spread along most: a channel that
// varies on its own is already an eigenvector, so a single start there would never leave it
// even when the others vary more together.
let spread = |a: [f32; 3]| times(a).iter().zip(a).map(|(x, y)| x * y).sum::<f32>();
let axis = [0, 1, 2].map(iterate).into_iter().max_by(|&a, &b| spread(a).total_cmp(&spread(b))).unwrap_or([1.0, 0.0, 0.0]);
let along = |c: &[f32; 3]| (c[0] - mean[0]) * axis[0] + (c[1] - mean[1]) * axis[1] + (c[2] - mean[2]) * axis[2];
let (lo, hi) = colours.iter().map(along).fold((f32::MAX, f32::MIN), |(lo, hi), t| (lo.min(t), hi.max(t)));
let at = |t: f32| [0, 1, 2].map(|i| (mean[i] + t * axis[i]).clamp(0.0, 255.0));
if lo > hi { (mean, mean) } else { (at(lo), at(hi)) }
}
/// The endpoints that minimise the squared error for the indices `bytes` already chose: each
/// texel's index fixes its weights between the endpoints, giving 2×2 normal equations per
/// channel. `None` when the indices don't constrain both endpoints.
fn least_squares(block: &Block, opaque: Mask, bytes: &[u8; 8]) -> Option<(u16, u16)> {
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let four = c0 > c1;
// How much of c0 each index holds.
let weight = |i: u32| match (four, i) {
(_, 0) => Some(1.0f32),
(_, 1) => Some(0.0),
(true, 2) => Some(2.0 / 3.0),
(true, _) => Some(1.0 / 3.0),
(false, 2) => Some(0.5),
(false, _) => None,
};
let (mut aa, mut ab, mut bb) = (0f32, 0f32, 0f32);
let (mut ax, mut bx) = ([0f32; 3], [0f32; 3]);
for (t, texel) in block.iter().enumerate() {
if opaque >> t & 1 == 0 {
continue;
}
let Some(a) = weight(indices >> (2 * t) & 3) else { continue };
let b = 1.0 - a;
let c = rgb_of(texel);
aa += a * a;
ab += a * b;
bb += b * b;
for ((x0, x1), v) in ax.iter_mut().zip(bx.iter_mut()).zip(c) {
*x0 += a * v;
*x1 += b * v;
}
}
let det = aa * bb - ab * ab;
if det.abs() < 1e-3 {
return None;
}
let e0 = [0, 1, 2].map(|i| (ax[i] * bb - bx[i] * ab) / det);
let e1 = [0, 1, 2].map(|i| (bx[i] * aa - ax[i] * ab) / det);
Some((to565(e0), to565(e1)))
}
/// A BC3 alpha block: whichever of the two modes (eight values between the extremes, or six
/// between the extremes other than 0 and 255, plus exact 0 and 255) fits better.
fn alpha_block(block: &Block, inside: Mask) -> [u8; 8] {
let alphas = || block.iter().enumerate().filter(move |(t, _)| inside >> t & 1 != 0).map(|(_, texel)| texel[3]);
let range = |values: &mut dyn Iterator<Item = u8>| values.fold(None, |r: Option<(u8, u8)>, a| Some(r.map_or((a, a), |(lo, hi)| (lo.min(a), hi.max(a)))));
let (lo, hi) = range(&mut alphas()).unwrap_or((0, 0));
// Eight values need a0 > a1; equal ends fall to the six-value mode, which starts with a0 too.
let eight = alpha_fit(block, inside, hi, lo);
let (mid_lo, mid_hi) = range(&mut alphas().filter(|a| !matches!(a, 0 | 255))).unwrap_or((0, 0));
let six = alpha_fit(block, inside, mid_lo, mid_hi);
if six.1 < eight.1 { six.0 } else { eight.0 }
}
fn alpha_fit(block: &Block, inside: Mask, a0: u8, a1: u8) -> ([u8; 8], u32) {
let palette = alpha_palette(a0, a1);
let mut bits = 0u64;
let mut error = 0u32;
for (t, texel) in block.iter().enumerate() {
if inside >> t & 1 == 0 {
continue;
}
let (i, d) = palette.iter().enumerate().map(|(i, &p)| (i, u32::from(p.abs_diff(texel[3])).pow(2))).min_by_key(|&(_, d)| d).unwrap_or((0, 0));
error += d;
bits |= (i as u64) << (3 * t);
}
let b = bits.to_le_bytes();
([a0, a1, b[0], b[1], b[2], b[3], b[4], b[5]], error)
}
#[cfg(test)]
mod tests {
use super::*;
fn decode_colour(bytes: &[u8; 8], bc1: bool) -> Block {
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
let palette = colour_palette(c0, c1, c0 > c1 || !bc1);
let indices = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
#[test]
fn solid_blocks_land_on_the_nearest_565_colour() {
for colour in [[0, 0, 0, 255], [255, 255, 255, 255], [12, 200, 77, 255]] {
let block = [colour; 16];
let out = decode_colour(&encode_bc1(&block, u16::MAX), true);
for texel in out {
assert!(texel.iter().zip(colour).all(|(a, b)| a.abs_diff(b) <= 4), "{texel:?} for {colour:?}");
}
}
}
#[test]
fn two_colour_blocks_are_exact_when_the_colours_are_565() {
let (a, b) = ([255, 0, 0, 255], [0, 0, 255, 255]);
let block: Block = std::array::from_fn(|t| if t % 3 == 0 { a } else { b });
for bc1 in [true, false] {
let bytes = if bc1 { encode_bc1(&block, u16::MAX) } else { colour_block(&block, u16::MAX, 0, false) };
assert_eq!(decode_colour(&bytes, bc1), block);
}
}
#[test]
fn bc1_punches_through_texels_under_half_alpha() {
let block: Block = std::array::from_fn(|t| if t < 8 { [200, 100, 50, 255] } else { [9, 9, 9, 127] });
let bytes = encode_bc1(&block, u16::MAX);
let (c0, c1) = (u16::from_le_bytes([bytes[0], bytes[1]]), u16::from_le_bytes([bytes[2], bytes[3]]));
assert!(c0 <= c1, "three-colour mode");
let out = decode_colour(&bytes, true);
assert!(out[..8].iter().all(|t| t[3] == 255) && out[8..].iter().all(|t| *t == [0; 4]), "{out:?}");
}
#[test]
fn four_colour_blocks_keep_c0_above_c1() {
// A ramp needs all four colours; BC2 and BC3 blocks must not rely on the order, but
// older decoders read c0 <= c1 as three colours, so the encoder never writes that.
let block: Block = std::array::from_fn(|t| [(t * 16) as u8, 0, 0, 255]);
let bytes = colour_block(&block, u16::MAX, 0, false);
assert!(u16::from_le_bytes([bytes[0], bytes[1]]) > u16::from_le_bytes([bytes[2], bytes[3]]));
}
#[test]
fn alpha_blocks_pick_the_mode_that_keeps_0_and_255() {
// 0, 255 and a cluster in between: the six-value mode stores 0 and 255 exactly.
let block: Block = std::array::from_fn(|t| [0, 0, 0, [0, 255, 100, 110][t % 4]]);
let bytes = alpha_block(&block, u16::MAX);
assert!(bytes[0] <= bytes[1], "six-value mode");
let palette = alpha_palette(bytes[0], bytes[1]);
let bits = u64::from_le_bytes([bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], 0, 0]);
for (t, texel) in block.iter().enumerate() {
let got = palette[(bits >> (3 * t)) as usize & 7];
assert!(got.abs_diff(texel[3]) <= 2, "texel {t}: {got} for {}", texel[3]);
}
}
#[test]
fn the_principal_axis_is_found_when_one_channel_varies_on_its_own() {
// Red alternates on its own (variance 64); green and blue alternate together (variance 49
// each, 98 along their shared axis), uncorrelated with red. Starting on red alone would
// keep red's axis and flatten green and blue; the block must do better than its bounding box.
let block: Block = std::array::from_fn(|t| {
let gb = if (t / 2) % 2 == 0 { 121 } else { 135 };
[if t % 2 == 0 { 120 } else { 136 }, gb, gb, 255]
});
let error = |bytes: &[u8; 8]| decode_colour(bytes, false).iter().zip(&block).map(|(a, b)| distance(a, b)).sum::<u32>();
let (_, bounding_box) = fit(&block, u16::MAX, 0, to565([120.0, 121.0, 121.0]), to565([136.0, 135.0, 135.0]), true);
let ours = error(&colour_block(&block, u16::MAX, 0, false));
assert!(ours < bounding_box, "{ours} vs the bounding box's {bounding_box}");
}
#[test]
fn texels_outside_the_image_are_ignored() {
// Only the top-left texel is inside: its colour alone decides the endpoints.
let mut block: Block = [[0, 255, 0, 255]; 16];
block[0] = [255, 0, 255, 255];
let out = decode_colour(&encode_bc1(&block, 1), true);
assert_eq!(out[0], [255, 0, 255, 255]);
}
}
+4 -15
View File
@@ -15,7 +15,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name};
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels};
use crate::options::{EncodeOptions, ExrCompression, Limits};
const F: Format = Format::OpenExr;
@@ -123,20 +123,9 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
let (w, h) = (layer.size.0, layer.size.1);
let channels = &layer.channel_data.list;
let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n);
let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) {
match find("A") {
Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba),
None => (vec![r, g, b], ChannelLayout::Rgb),
}
} else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) {
match find("A") {
Some(a) => (vec![y, a], ChannelLayout::GrayA),
None => (vec![y], ChannelLayout::Gray),
}
} else {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
};
let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect();
let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?;
let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::<Result<_, _>>()?;
let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 };
let npx = w * h;
+126
View File
@@ -0,0 +1,126 @@
//! The colour table of a GIF, read from its blocks without decoding pixels (GIF89a
//! specification: Logical Screen Descriptor, Global and Local Color Tables, Graphic Control
//! Extension).
//!
//! A GIF is always palette-based, so it opens in Indexed Color with the table of the frame that
//! [`crate::decode`] keeps, the first: its Local Color Table when it has one, else the Global
//! one, and the transparent entry that frame's Graphic Control Extension names.
/// Colours of the table the first frame uses, and its transparent entry. `None` for anything
/// that isn't a well-formed GIF up to its first image, or a first frame without a table.
pub(crate) fn palette(b: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
if !(b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a")) {
return None;
}
// Logical Screen Descriptor: width, height, packed fields, background, aspect.
let packed = *b.get(10)?;
let mut at = 13;
let table = |at: usize, packed: u8| -> Option<Vec<[u8; 3]>> {
let n = 2usize << (packed & 0x07);
let bytes = b.get(at..at.checked_add(n.checked_mul(3)?)?)?;
Some(bytes.as_chunks::<3>().0.to_vec())
};
let global = if packed & 0x80 != 0 {
let t = table(at, packed)?;
at += t.len() * 3;
Some(t)
} else {
None
};
let mut transparent = None;
loop {
match *b.get(at)? {
// Extension: label, then data sub-blocks up to a zero-length one.
0x21 => {
let label = *b.get(at + 1)?;
at += 2;
// Graphic Control Extension: block size 4, packed (bit 0: transparent colour
// given), delay (2 bytes), transparent colour index.
if label == 0xF9 && *b.get(at)? == 4 {
let flags = *b.get(at + 1)?;
transparent = (flags & 0x01 != 0).then_some(*b.get(at + 4)?);
}
loop {
let size = usize::from(*b.get(at)?);
at = at.checked_add(1 + size)?;
if size == 0 {
break;
}
}
}
// Image Descriptor: position and size (8 bytes), packed fields, then the Local Color
// Table if bit 7 is set. This is the first frame: its table is the one used.
0x2C => {
let packed = *b.get(at + 9)?;
let colors = if packed & 0x80 != 0 { table(at + 10, packed)? } else { global? };
let transparent = transparent.filter(|i| usize::from(*i) < colors.len());
return Some((colors, transparent));
}
// Trailer, or anything else: no image.
_ => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::palette;
/// A GIF up to its first image: `global` table entries, optional GCE transparency, optional
/// local table.
fn gif(global: &[[u8; 3]], transparent: Option<u8>, local: Option<&[[u8; 3]]>) -> Vec<u8> {
let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8;
let mut b = b"GIF89a".to_vec();
b.extend([4, 0, 4, 0]);
b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) });
b.extend([0, 0]);
let pad = |b: &mut Vec<u8>, t: &[[u8; 3]]| {
let n = 2usize << size_bits(t.len());
for k in 0..n {
b.extend(t.get(k).copied().unwrap_or([0, 0, 0]));
}
};
if !global.is_empty() {
pad(&mut b, global);
}
// A comment extension first, to skip.
b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]);
if let Some(i) = transparent {
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]);
}
b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]);
match local {
Some(t) => {
b.push(0x80 | size_bits(t.len()));
pad(&mut b, t);
}
None => b.push(0),
}
b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]);
b
}
#[test]
fn the_first_frames_table_and_transparent_entry() {
let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];
assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None)));
assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2))));
// A local table wins over the global one.
let l = [[1, 2, 3], [4, 5, 6]];
assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1))));
// A transparent index past the table is no transparent entry.
assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None)));
}
#[test]
fn no_table_or_not_a_gif_is_none() {
assert_eq!(palette(&gif(&[], None, None)), None, "no table at all");
assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None);
assert_eq!(palette(b""), None);
let whole = gif(&[[9, 9, 9], [8, 8, 8]], Some(0), None);
for cut in 0..whole.len() - 6 {
// Cut short anywhere before the image descriptor's fields: never a panic.
let _ = palette(&whole[..cut]);
}
}
}
+4 -1
View File
@@ -33,6 +33,8 @@ pub(crate) struct JpegMeta {
pub exif: Option<Vec<u8>>,
pub xmp: Option<String>,
pub dpi: Option<(f32, f32)>,
/// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header.
pub photoshop: Option<Vec<u8>>,
pub adobe_transform: Option<u8>,
/// (width, height, components) from the first SOFn.
pub frame: Option<(u32, u32, u8)>,
@@ -108,6 +110,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
.or_else(|| m.xmp.as_deref().and_then(xmp_resolution))
.or_else(|| m.exif.as_deref().and_then(exif_resolution))
.or(jfif_dpi);
m.photoshop = Some(photoshop).filter(|p| !p.is_empty());
if !icc_parts.is_empty() {
icc_parts.sort_by_key(|p| p.0);
let total = icc_parts[0].1 as usize;
@@ -226,7 +229,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
img.icc = meta.icc;
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() };
if end == DataEnd::Truncated {
img.warnings.push(DecodeWarning::Truncated { format: F });
}
+2
View File
@@ -1,10 +1,12 @@
pub(crate) mod dds;
pub(crate) mod dds_bc;
pub(crate) mod deep_exr;
pub(crate) mod dpx;
pub(crate) mod exr;
pub(crate) mod exr_cryptomatte;
pub(crate) mod fits;
pub(crate) mod gif_frames;
pub(crate) mod gif_palette;
pub(crate) mod heif;
pub(crate) mod jpeg;
pub(crate) mod pcx;
+244 -24
View File
@@ -7,7 +7,7 @@ use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed};
const F: Format = Format::Png;
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
@@ -127,6 +127,38 @@ pub fn palette(bytes: &[u8]) -> Result<Option<PngPalette>, CodecError> {
Ok(Some(PngPalette { colors, transparent }))
}
/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read
/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a
/// defined mode byte. The first valid chunk wins, as a later one would contradict it.
pub fn stereo(bytes: &[u8]) -> Option<PngStereo> {
let mut rest = bytes.get(8..)?;
while let Some((length, tail)) = rest.split_first_chunk::<4>() {
let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break };
let length = u32::from_be_bytes(*length);
if length > i32::MAX as u32 {
break;
}
let Ok(length) = usize::try_from(length) else { break };
let Some(data) = tail.get(..length) else { break };
let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break };
rest = next;
if kind == b"IEND" {
break;
}
if kind != b"sTER" || data.len() != 1 {
continue;
}
let mut crc = flate2::Crc::new();
crc.update(kind);
crc.update(data);
if crc.sum() != u32::from_be_bytes(*checksum) {
continue;
}
return PngStereo::from_mode(data[0]);
}
None
}
fn map_png_err(e: png::DecodingError) -> CodecError {
match e {
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
@@ -346,6 +378,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
}
}
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(stereo) = opts.png_stereo {
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
}
if opts.png_interlaced {
let bpp = img.layout().channels() * img.sample_type().bytes();
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
@@ -505,8 +541,15 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
}
};
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
let mut best = (cost(row), 0u8);
let mut best = cost(row);
let start = out.len();
out.push(0);
out.extend_from_slice(row);
for kind in 1u8..=4 {
if best == 0 {
break;
}
let mut c = 0u64;
for i in 0..n {
let x = row[i];
scratch[i] = match kind {
@@ -515,27 +558,17 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
};
c += u64::from((scratch[i] as i8).unsigned_abs());
if c >= best {
break;
}
}
let c = cost(&scratch[..n]);
if c < best.0 {
best = (c, kind);
if c < best {
best = c;
out[start] = kind;
out[start + 1..].copy_from_slice(&scratch[..n]);
}
}
out.push(best.1);
if best.1 == 0 {
out.extend_from_slice(row);
return;
}
for i in 0..n {
let x = row[i];
scratch[i] = match best.1 {
1 => x.wrapping_sub(left(i)),
2 => x.wrapping_sub(up(i)),
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
};
}
out.extend_from_slice(&scratch[..n]);
}
const ADLER_MOD: u32 = 65_521;
@@ -574,6 +607,7 @@ pub fn encode_indexed(
palette: &[[u8; 3]],
transparent: Option<u8>,
dpi: Option<(f32, f32)>,
stereo: Option<PngStereo>,
) -> Result<Vec<u8>, CodecError> {
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
return Err(CodecError::InvalidImage("index data does not match the size".into()));
@@ -603,12 +637,95 @@ pub fn encode_indexed(
{
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(stereo) = stereo {
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
}
writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?;
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
}
#[cfg(test)]
mod stereo_tests {
use super::*;
/// One CRC-valid chunk as it appears in a file.
fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
let mut crc = flate2::Crc::new();
crc.update(kind);
crc.update(data);
out.extend_from_slice(&crc.sum().to_be_bytes());
out
}
fn png_with(stereo: &[u8]) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0]));
if !stereo.is_empty() {
out.extend_from_slice(&chunk(b"sTER", stereo));
}
out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0]));
out.extend_from_slice(&chunk(b"IEND", &[]));
out
}
#[test]
fn reads_both_defined_views() {
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal));
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical));
assert_eq!(stereo(&png_with(&[])), None);
// Reserved and unknown mode bytes are not a pairing we can keep.
assert_eq!(stereo(&png_with(&[2])), None);
assert_eq!(stereo(&png_with(&[255])), None);
// A two-byte payload is not sTER data.
assert_eq!(stereo(&png_with(&[0, 0])), None);
assert_eq!(stereo(b"not a png"), None);
}
#[test]
fn a_broken_crc_is_not_a_stereo_mark() {
let mut bytes = png_with(&[0]);
let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap();
// The CRC trails the type and the one-byte payload of the chunk.
bytes[kind + 8] ^= 0xff;
assert_eq!(stereo(&bytes), None);
}
#[test]
fn encode_writes_the_chunk_before_idat_and_it_reads_back() {
let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap();
for view in [PngStereo::Horizontal, PngStereo::Vertical] {
let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() };
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
assert_eq!(stereo(&bytes), Some(view));
let back = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(back.data(), img.data());
let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap();
let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap();
assert!(ter < idat, "sTER before IDAT");
}
let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert_eq!(stereo(&plain), None);
}
#[test]
fn interlaced_and_indexed_encoders_keep_the_mark() {
let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap();
let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() };
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
assert_eq!(stereo(&bytes), Some(PngStereo::Vertical));
let pal = [[255, 0, 0], [0, 0, 255]];
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap();
assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal));
assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec()));
}
}
#[cfg(test)]
mod indexed_tests {
use super::*;
@@ -617,14 +734,14 @@ mod indexed_tests {
fn png8_roundtrips_through_the_decoder() {
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
let idx = [0u8, 1, 2, 1, 0, 2];
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap();
let img = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
assert_eq!(px.data()[11], 0, "transparent entry");
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err());
}
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec<u8> {
@@ -643,7 +760,7 @@ mod indexed_tests {
#[test]
fn palette_reads_the_table_and_its_one_transparent_entry() {
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap();
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap();
let colors = vec![[1, 2, 3], [4, 5, 6]];
assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) }));
assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None }));
@@ -659,6 +776,109 @@ mod indexed_tests {
mod parallel_tests {
use super::*;
#[test]
#[ignore = "manual 24 MP release benchmark"]
fn benchmark_filter_selection() {
use std::{hint::black_box, time::Instant};
for pattern in ["zero", "identical", "varied"] {
let row: Vec<u8> = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect();
let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect();
let mut scratch = vec![0; row.len()];
let mut out = Vec::with_capacity(row.len() + 1);
for trial in 0..6 {
type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec<u8>);
let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)];
if trial % 2 == 1 {
paths.reverse();
}
for (name, path) in paths {
let t = Instant::now();
for _ in 0..4000 {
out.clear();
black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out);
black_box(&out);
}
println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0);
}
}
}
}
// The main-branch implementation before this refactor.
fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec<u8>) {
let n = row.len();
let up = |i: usize| prev.map_or(0, |p| p[i]);
let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 };
let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 };
let paeth = |a: u8, b: u8, c: u8| {
let p = i16::from(a) + i16::from(b) - i16::from(c);
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
};
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
let mut best = (cost(row), 0u8);
for kind in 1u8..=4 {
for i in 0..n {
let x = row[i];
scratch[i] = match kind {
1 => x.wrapping_sub(left(i)),
2 => x.wrapping_sub(up(i)),
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
};
}
let c = cost(&scratch[..n]);
if c < best.0 {
best = (c, kind);
}
}
out.push(best.1);
if best.1 == 0 {
out.extend_from_slice(row);
return;
}
for i in 0..n {
let x = row[i];
scratch[i] = match best.1 {
1 => x.wrapping_sub(left(i)),
2 => x.wrapping_sub(up(i)),
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
};
}
out.extend_from_slice(&scratch[..n]);
}
#[test]
fn adaptive_filter_preserves_minimum_cost_and_ties() {
for bpp in [1, 2, 3, 4, 6, 8] {
for seed in 0..32usize {
let row: Vec<u8> = (0..137).map(|i| (i * seed + i / 5) as u8).collect();
let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect();
for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] {
let mut expected = vec![99];
let mut actual = expected.clone();
let mut scratch = vec![0; row.len()];
previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected);
filter_row(&row, prev, bpp, &mut scratch, &mut actual);
assert_eq!(actual, expected, "bpp={bpp}, seed={seed}");
}
}
}
let mut out = Vec::new();
filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out);
assert_eq!(out, vec![0; 17]);
out.clear();
filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out);
assert_eq!(out, [vec![1, 1], vec![0; 15]].concat());
}
#[test]
fn adler_combine_matches_one_pass() {
let a: Vec<u8> = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect();
+36 -10
View File
@@ -6,7 +6,9 @@
//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own
//! directories, writing one copies the block's directories into the file. Values are copied as
//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive.
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is
//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can
//! overlap (see [`read`]).
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
use tiff::tags::{Tag, Type};
@@ -27,6 +29,11 @@ const TAG_INTEROP_IFD: u16 = 40965;
const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432];
/// At most this many entries are copied from one directory.
const MAX_FIELDS: usize = 1024;
/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF
/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for
/// that and for a few such notes. A stream never yields more than its own length either (see
/// [`read`]).
const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024;
/// One entry with its value bytes in the byte order of the stream it came from.
#[derive(Clone, Debug, PartialEq)]
@@ -53,13 +60,22 @@ impl ExifDirs {
}
}
fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec<Field> {
ifd.entries
.iter()
.filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD))
.filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() }))
.take(MAX_FIELDS)
.collect()
/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has
/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may
/// still be copied (shared by all the directories of one stream), and an entry whose value does
/// not fit in what is left is skipped, so later, smaller entries can still be kept.
fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec<Field> {
let mut out = Vec::new();
for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) {
if out.len() >= MAX_FIELDS {
break;
}
let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue };
let Some(left) = budget.checked_sub(value.len()) else { continue };
*budget = left;
out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() });
}
out
}
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
@@ -69,14 +85,24 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its
/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories.
///
/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values
/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries
/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what
/// is left are left out (the small descriptive directories come first, so a huge maker note
/// cannot crowd out the GPS position).
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
let f = File::parse(b)?;
if f.big {
return None;
}
let ifd0 = f.ifd(f.first_ifd)?;
let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default();
Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) })
let mut budget = MAX_COPIED_BYTES.min(b.len());
let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t));
let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default();
let gps = dir(TAG_GPS_IFD);
let exif = dir(TAG_EXIF_IFD);
Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps })
}
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
+6 -3
View File
@@ -6,7 +6,7 @@ use std::fmt;
use crate::format::{Format, caps};
use crate::image::{ChannelLayout, Image, SampleType};
use crate::options::EncodeOptions;
use crate::options::{DdsCompression, EncodeOptions};
/// Something that will be lost or altered when writing an image to a format.
#[derive(Debug, Clone, PartialEq)]
@@ -165,6 +165,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
// A block-compressed DDS stores RGBA whatever the source; uncompressed keeps gray and RGB.
Format::Dds if opts.dds.compression.is_block() => Plan { layout: ChannelLayout::Rgba, sample: SampleType::U8 },
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -211,7 +213,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaDiscarded);
}
if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() {
let binary_alpha = format == Format::Gif || (format == Format::Dds && opts.dds.compression == DdsCompression::Bc1);
if binary_alpha && p.layout.has_alpha() && image.has_translucency() {
w.push(W::AlphaBinarized);
}
if sl.is_cmyk() && !p.layout.is_cmyk() {
@@ -227,7 +230,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
// Compression
if format == Format::Gif {
w.push(W::PaletteQuantized);
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) {
} else if c.lossy || (format == Format::WebP && !opts.webp_lossless) || (format == Format::Dds && opts.dds.compression.is_block()) {
w.push(W::LossyCompression);
}
+4 -6
View File
@@ -19,7 +19,8 @@ pub enum Format {
Pcx,
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
Xpm,
/// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
/// DirectDraw Surface: a 2D texture, uncompressed or BC1–BC3. Reading takes the top mip
/// level; writing can add the full mip chain ([`crate::DdsOptions`]).
Dds,
Qoi,
OpenExr,
@@ -71,10 +72,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
Format::Pcx,
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
),
(
Format::Dds,
"DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.",
),
(
Format::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
@@ -255,7 +252,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base },
Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base },
// Uncompressed by default; a block compression is lossy (see `fidelity_warnings_with`).
Format::Dds => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Qoi => base,
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
+64 -9
View File
@@ -321,20 +321,75 @@ pub struct ExrPartInfo {
impl ExrPartInfo {
/// How much this part looks like a colour image the plain [`Image`] decode can show:
/// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`).
/// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`).
/// The colour and alpha channels must share a complete layer prefix. The highest-ranked
/// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically.
/// The auto-pick takes the highest rank; ties go to the earlier part.
pub fn color_rank(&self) -> u8 {
let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n);
if has("R") && has("G") && has("B") {
return if has("A") { 4 } else { 3 };
}
if has("Y") {
return if has("A") { 2 } else { 1 };
}
u8::from(self.channels.len() == 1)
exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8)
}
}
/// One selection policy for header ranking/probing and flat decoding: never synthesize
/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last
/// dot, including nested layers; a leading or trailing dot alone does not name a layer.
pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator<Item = &'a str>) -> Option<(ChannelLayout, Vec<usize>)> {
#[derive(Default)]
struct Group {
r: Option<usize>,
g: Option<usize>,
b: Option<usize>,
a: Option<usize>,
y: Option<usize>,
}
// Sorted prefixes make rank ties deterministic independently of channel order, with
// the empty (unprefixed) group first. Unsupported channels cannot complete a group.
let mut groups = std::collections::BTreeMap::<&str, Group>::new();
let mut count = 0usize;
for (index, name) in names.into_iter().enumerate() {
count = count.saturating_add(1);
let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name));
if !matches!(base, "R" | "G" | "B" | "A" | "Y") {
continue;
}
let group = groups.entry(prefix).or_default();
let component = match base {
"R" => &mut group.r,
"G" => &mut group.g,
"B" => &mut group.b,
"A" => &mut group.a,
"Y" => &mut group.y,
_ => continue,
};
component.get_or_insert(index);
}
let mut best = None;
let mut best_rank = 0;
for group in groups.values() {
let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) {
match group.a {
Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]),
None => (ChannelLayout::Rgb, vec![r, g, b]),
}
} else if let Some(y) = group.y {
match group.a {
Some(a) => (ChannelLayout::GrayA, vec![y, a]),
None => (ChannelLayout::Gray, vec![y]),
}
} else {
continue;
};
let rank = candidate.0.channels();
if rank > best_rank {
best = Some(candidate);
best_rank = rank;
}
}
best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0])))
}
/// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`).
pub(crate) fn base_channel_name(name: &str) -> &str {
name.rsplit('.').next().unwrap_or(name)
+11 -3
View File
@@ -28,7 +28,7 @@ pub mod web;
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette};
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
pub use crate::error::CodecError;
@@ -38,7 +38,9 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
pub use crate::image::{
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
};
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::options::{
DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression,
};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
@@ -126,6 +128,12 @@ pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
}
/// The colour table of a GIF's first frame (its Local Color Table, else the Global one) and the
/// entry its Graphic Control Extension makes transparent, read without decoding pixels.
pub fn gif_palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
crate::codecs::gif_palette::palette(bytes)
}
/// Returns the embedded colour table for an indexed BMP, if it has one.
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
via_image::bmp_palette(bytes)
@@ -252,7 +260,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Pnm => pnm::encode(image, plan, opts),
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")),
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
Format::Dds => dds::encode(image, plan, opts),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
+90
View File
@@ -100,6 +100,34 @@ pub enum PngCompression {
Best,
}
/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PngStereo {
/// Left and right views side by side, left first.
Horizontal,
/// Left and right views stacked, left on top.
Vertical,
}
impl PngStereo {
/// The view for an `sTER` mode byte; reserved and unknown values are `None`.
pub fn from_mode(mode: u8) -> Option<Self> {
match mode {
0 => Some(Self::Horizontal),
1 => Some(Self::Vertical),
_ => None,
}
}
/// The `sTER` mode byte.
pub fn mode(self) -> u8 {
match self {
Self::Horizontal => 0,
Self::Vertical => 1,
}
}
}
/// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the
/// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -154,6 +182,61 @@ impl DpxBits {
}
}
/// How a DDS texture stores its pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DdsCompression {
/// Uncompressed, 8 bits per channel: BGRA (BGRX without alpha), or luminance with or
/// without alpha for grayscale. Lossless.
#[default]
None,
/// BC1 (DXT1): 4 bits per pixel. Alpha is on or off: texels under half opacity become
/// transparent black.
Bc1,
/// BC2 (DXT3): 8 bits per pixel, with 4 bits of explicit alpha per texel.
Bc2,
/// BC3 (DXT5): 8 bits per pixel, with smooth interpolated alpha.
Bc3,
}
impl DdsCompression {
pub const ALL: [DdsCompression; 4] = [DdsCompression::None, DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
/// The name scripts and the CLI use: `none`, `bc1`, `bc2` or `bc3`.
pub fn name(self) -> &'static str {
match self {
DdsCompression::None => "none",
DdsCompression::Bc1 => "bc1",
DdsCompression::Bc2 => "bc2",
DdsCompression::Bc3 => "bc3",
}
}
/// [`Self::name`] back to a compression, also taking the DXT names (`dxt1`, `dxt3`,
/// `dxt5`), in any case. Anything else is `None`.
pub fn from_name(name: &str) -> Option<Self> {
match name.to_ascii_lowercase().as_str() {
"none" => Some(DdsCompression::None),
"bc1" | "dxt1" => Some(DdsCompression::Bc1),
"bc2" | "dxt3" => Some(DdsCompression::Bc2),
"bc3" | "dxt5" => Some(DdsCompression::Bc3),
_ => None,
}
}
/// A block compression (lossy), not uncompressed pixels.
pub fn is_block(self) -> bool {
self != DdsCompression::None
}
}
/// What a DDS file is written with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DdsOptions {
pub compression: DdsCompression,
/// Write the full mip chain down to 1×1, each level a box filter of the one above.
pub mipmaps: bool,
}
/// Options for [`crate::encode`].
#[derive(Debug, Clone)]
pub struct EncodeOptions {
@@ -164,6 +247,9 @@ pub struct EncodeOptions {
pub png_compression: PngCompression,
/// Write Adam7-interlaced PNG.
pub png_interlaced: bool,
/// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6),
/// so a stereo PNG keeps its mark when saved again (#6063).
pub png_stereo: Option<PngStereo>,
/// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see
/// `codecs::vp8`) with [`Self::webp_quality`].
pub webp_lossless: bool,
@@ -176,6 +262,8 @@ pub struct EncodeOptions {
pub exr_compression: ExrCompression,
/// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise.
pub dpx_bits: Option<DpxBits>,
/// DDS compression and mip chain.
pub dds: DdsOptions,
/// Embed the ICC profile when the format supports it.
pub embed_icc: bool,
/// Embed EXIF/XMP/DPI/text when the format supports it.
@@ -189,12 +277,14 @@ impl Default for EncodeOptions {
jpeg_chroma_subsampling: true,
png_compression: PngCompression::Default,
png_interlaced: false,
png_stereo: None,
webp_lossless: true,
webp_quality: 80,
tiff_compression: TiffCompression::None,
tiff_bigtiff: false,
exr_compression: ExrCompression::Zip16,
dpx_bits: None,
dds: DdsOptions::default(),
embed_icc: true,
embed_metadata: true,
}
+19 -7
View File
@@ -285,7 +285,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec<bool>), CodecError> {
}
/// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP.
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and
/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]).
#[allow(clippy::too_many_arguments)]
pub fn encode_jpeg_rgb8(
width: u32,
@@ -296,6 +297,7 @@ pub fn encode_jpeg_rgb8(
optimized: bool,
icc: Option<&[u8]>,
dpi: Option<f32>,
exif: Option<&[u8]>,
xmp: Option<&str>,
) -> Result<Vec<u8>, CodecError> {
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e);
@@ -319,9 +321,14 @@ pub fn encode_jpeg_rgb8(
let v = d.round().min(65535.0) as u16;
enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v });
}
let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d));
// Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it.
if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) {
enc.add_app_segment(1, &seg).map_err(e)?;
}
if let Some(x) = xmp {
// Its `tiff:` resolution, if any, says what the JFIF density says (#1691).
let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d)));
let x = crate::resolution::export_xmp(x, ppi);
let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
seg.extend_from_slice(x.as_bytes());
enc.add_app_segment(1, &seg).map_err(e)?;
@@ -333,6 +340,11 @@ pub fn encode_jpeg_rgb8(
Ok(out)
}
/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment).
pub fn jpeg_exif_fits(exif: &[u8], dpi: Option<f32>) -> bool {
crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -359,7 +371,7 @@ mod tests {
let rgb: Vec<u8> = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect();
for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] {
for progressive in [false, true] {
let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap();
let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap();
assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}");
}
}
@@ -428,8 +440,8 @@ mod tests {
#[test]
fn progressive_jpeg_decodes() {
let rgb: Vec<u8> = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect();
let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("<x:xmpmeta/>")).unwrap();
let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap();
let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("<x:xmpmeta/>")).unwrap();
let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap();
assert_ne!(base, prog);
assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("<x:xmpmeta/>"));
// SOF2 marks a progressive frame.
@@ -442,7 +454,7 @@ mod tests {
fn web_jpeg_xmp_resolution_follows_the_density() {
let rgb = vec![100u8; 8 * 8 * 3];
let xmp = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution="300/1"/>"#;
let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap();
let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap();
let img = crate::decode(&out).unwrap();
assert_eq!(img.meta.dpi, Some((72.0, 72.0)));
assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0)));
@@ -461,7 +473,7 @@ mod subsampling_tests {
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect();
for q in [30, 60, 95] {
for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] {
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap();
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap();
let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8);
let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap();
assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}");
+5 -4
View File
@@ -59,11 +59,12 @@ const WHITE: u16 = 0xffff;
const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0];
#[test]
fn dds_is_readable_but_not_writable() {
fn dds_is_readable_and_writable() {
assert_eq!(from_extension("texture.DDS"), Some(Format::Dds));
assert!(caps(Format::Dds).read && !caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap();
assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. })));
assert!(caps(Format::Dds).read && caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![1, 2, 3, 0]).unwrap();
let b = encode(&img, Format::Dds, &Default::default()).unwrap();
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 0]);
}
#[test]
+372
View File
@@ -0,0 +1,372 @@
//! Writing DDS textures: every storage read back through the reader, the header fields, sizes
//! that aren't a multiple of four, the mip chain, and the block-compression error on ramps.
use photocraft_codecs::{ChannelLayout, DdsCompression, DdsOptions, EncodeOptions, FidelityWarning, Format, Image, decode, encode, fidelity_warnings_with};
const DDSD_PITCH: u32 = 0x8;
const DDSD_MIPMAPCOUNT: u32 = 0x2_0000;
const DDSD_LINEARSIZE: u32 = 0x8_0000;
const REQUIRED: u32 = 0x1007; // CAPS | HEIGHT | WIDTH | PIXELFORMAT
const DDSCAPS_COMPLEX: u32 = 0x8;
const DDSCAPS_TEXTURE: u32 = 0x1000;
const DDSCAPS_MIPMAP: u32 = 0x40_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const BLOCKS: [DdsCompression; 3] = [DdsCompression::Bc1, DdsCompression::Bc2, DdsCompression::Bc3];
fn opts(compression: DdsCompression, mipmaps: bool) -> EncodeOptions {
EncodeOptions { dds: DdsOptions { compression, mipmaps }, ..Default::default() }
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// A ramp along one line through RGB (red up, green up half as fast, blue down) across the
/// texture, and alpha falling from opaque down it: every block's colours lie on a line, which
/// is what a colour block stores.
fn ramp(w: u32, h: u32, layout: ChannelLayout) -> Image {
let ch = layout.channels();
let at = |v: u32, n: u32| (v * 255 / (n - 1).max(1)) as u8;
let mut data = Vec::new();
for y in 0..h {
for x in 0..w {
let r = at(x, w);
let rgba = [r, 64 + r / 2, 255 - r, 255 - at(y, h)];
match ch {
1 => data.push(rgba[0]),
2 => data.extend([rgba[0], rgba[3]]),
_ => data.extend(&rgba[..ch]),
}
}
}
Image::from_u8(w, h, layout, data).unwrap()
}
/// The same RGBA image, fully opaque.
fn opaque(img: &Image) -> Image {
let data = img.data().chunks(4).flat_map(|p| [p[0], p[1], p[2], 255]).collect();
Image::from_u8(img.width(), img.height(), ChannelLayout::Rgba, data).unwrap()
}
/// Largest difference in channel `c` (of four) between RGBA images.
fn max_diff_channel(a: &Image, b: &Image, c: usize) -> u8 {
a.data().chunks(4).zip(b.data().chunks(4)).map(|(x, y)| x[c].abs_diff(y[c])).max().unwrap_or(0)
}
/// Largest colour difference between RGBA images where `b` is visible (BC1 turns texels under
/// half opacity into transparent black).
fn max_colour_diff(a: &Image, b: &Image) -> u8 {
a.data().chunks(4).zip(b.data().chunks(4)).filter(|(_, y)| y[3] != 0).flat_map(|(x, y)| (0..3).map(|c| x[c].abs_diff(y[c]))).max().unwrap_or(0)
}
/// A 2D gradient: red across, green down and blue along the diagonal, opaque.
fn gradient(w: u32, h: u32) -> Image {
let data = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 4).min(255) as u8, (y * 4).min(255) as u8, ((x + y) * 2).min(255) as u8, 255])).collect();
Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap()
}
/// PSNR of the colour (not the alpha) of two RGBA images.
fn psnr(a: &Image, b: &Image) -> f64 {
let rgb = |i: &Image| i.data().chunks(4).flat_map(|p| [p[0], p[1], p[2]]).map(f64::from).collect::<Vec<_>>();
let (a, b) = (rgb(a), rgb(b));
let mse = a.iter().zip(&b).map(|(x, y)| (x - y).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64.powi(2) / mse).log10() }
}
/// Bytes of one level as a reader computes them from the header's pixel format.
fn level_bytes(file: &[u8], w: usize, h: usize) -> usize {
if le32(file, 80) & DDPF_FOURCC != 0 {
let block = if &file[84..88] == b"DXT1" { 8 } else { 16 };
w.div_ceil(4) * h.div_ceil(4) * block
} else {
w * h * le32(file, 88) as usize / 8
}
}
/// Mip level `i` of a written texture, cut out as a single-level texture and decoded.
fn level(file: &[u8], i: u32) -> Image {
let (w, h) = (le32(file, 16) as usize, le32(file, 12) as usize);
let size = |i: u32| ((w >> i).max(1), (h >> i).max(1));
let start = 128 + (0..i).map(|j| level_bytes(file, size(j).0, size(j).1)).sum::<usize>();
let (lw, lh) = size(i);
let mut b = file[..128].to_vec();
b[12..16].copy_from_slice(&(lh as u32).to_le_bytes());
b[16..20].copy_from_slice(&(lw as u32).to_le_bytes());
b[28..32].copy_from_slice(&1u32.to_le_bytes());
b.extend(&file[start..start + level_bytes(file, lw, lh)]);
decode(&b).unwrap()
}
#[test]
fn uncompressed_textures_round_trip_every_layout_exactly() {
for (layout, pf_flags, bits, masks) in [
(ChannelLayout::Gray, DDPF_LUMINANCE, 8, [0xff, 0, 0, 0]),
(ChannelLayout::GrayA, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, 16, [0xff, 0, 0, 0xff00]),
(ChannelLayout::Rgb, DDPF_RGB, 32, [0xff_0000, 0xff00, 0xff, 0]),
(ChannelLayout::Rgba, DDPF_RGB | DDPF_ALPHAPIXELS, 32, [0xff_0000, 0xff00, 0xff, 0xff00_0000]),
] {
for (w, h) in [(1, 1), (5, 3), (17, 9)] {
let img = ramp(w, h, layout);
let b = encode(&img, Format::Dds, &EncodeOptions::default()).unwrap();
let back = decode(&b).unwrap();
assert_eq!(back.layout(), layout, "{layout:?} {w}x{h}");
assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}");
assert_eq!((&b[..4], le32(&b, 4), le32(&b, 76)), (&b"DDS "[..], 124, 32));
assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH, "pitch, not linear size, and no mip count");
assert_eq!((le32(&b, 12), le32(&b, 16)), (h, w));
assert_eq!(le32(&b, 20), w * bits / 8, "pitch of {layout:?} {w}x{h}");
assert_eq!((le32(&b, 28), le32(&b, 108)), (1, DDSCAPS_TEXTURE));
assert_eq!((le32(&b, 80), le32(&b, 88)), (pf_flags, bits));
assert_eq!([le32(&b, 92), le32(&b, 96), le32(&b, 100), le32(&b, 104)], masks);
assert_eq!(b.len(), 128 + (w * h * bits / 8) as usize);
}
}
}
#[test]
fn block_compressed_textures_round_trip_through_the_reader() {
for (compression, fourcc, block) in [(DdsCompression::Bc1, b"DXT1", 8), (DdsCompression::Bc2, b"DXT3", 16), (DdsCompression::Bc3, b"DXT5", 16)] {
// Not multiples of four, and the 1×1 and 2×2 textures smaller than one block.
for (w, h) in [(1, 1), (2, 2), (4, 4), (5, 3), (7, 13), (33, 17)] {
let img = ramp(w, h, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, false)).unwrap();
let linear = w.div_ceil(4) * h.div_ceil(4) * block;
assert_eq!(&b[84..88], fourcc);
assert_eq!((le32(&b, 80), le32(&b, 88)), (DDPF_FOURCC, 0));
assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE, "linear size, not pitch");
assert_eq!(le32(&b, 20), linear, "{compression:?} {w}x{h}: the top level's size");
assert_eq!(b.len(), 128 + linear as usize);
let back = decode(&b).unwrap();
assert_eq!((back.dimensions(), back.layout()), ((w, h), ChannelLayout::Rgba));
// Steep ramps across tiny textures put three colours where a block interpolates at
// thirds, so this bound is loose; it still catches swapped channels or misplaced
// blocks. `block_compression_error_on_gradients_is_bounded` is the tight one. A BC1
// block with transparent texels has only three colours, so BC1's colours are
// checked on the opaque ramp.
let (src, back_colour) = if compression == DdsCompression::Bc1 {
let o = opaque(&img);
let back = decode(&encode(&o, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
(o, back)
} else {
(img.clone(), back.clone())
};
let colour = max_colour_diff(&src, &back_colour);
assert!(colour <= 24, "{compression:?} {w}x{h}: colour off by {colour}");
let alpha = max_diff_channel(&img, &back, 3);
match compression {
DdsCompression::Bc1 => assert!(back.data().chunks(4).zip(img.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
_ => assert!(alpha <= 8, "{compression:?} {w}x{h}: alpha off by {alpha}"),
}
}
}
}
#[test]
fn gray_and_rgb_sources_are_written_as_rgba_blocks() {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb] {
for compression in BLOCKS {
let img = ramp(8, 8, layout);
let back = decode(&encode(&img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let want = img.convert(ChannelLayout::Rgba, img.sample_type());
assert_eq!(back.layout(), ChannelLayout::Rgba);
let colour = max_colour_diff(&want, &back);
assert!(colour <= 8, "{layout:?} {compression:?}: colour off by {colour}");
}
}
}
#[test]
fn block_compression_error_on_gradients_is_bounded() {
let (line, plane) = (ramp(64, 64, ChannelLayout::Rgba), gradient(64, 64));
for compression in BLOCKS {
let roundtrip = |img: &Image| decode(&encode(img, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let (line_back, plane_back) = (roundtrip(&line), roundtrip(&plane));
let (line_err, plane_err) = (max_colour_diff(&line, &line_back), max_colour_diff(&plane, &plane_back));
let p = psnr(&plane, &plane_back);
let alpha = max_diff_channel(&line, &line_back, 3);
// Colours on a line only lose the RGB565 rounding of the endpoints (up to 4 levels in
// red and blue). A gradient in two directions is not on a line: the spread off a
// block's best line alone limits this one to about 40 dB, and RGB565 costs a little
// more (the encoder measures 38.6 dB and 9 levels at most).
assert!(line_err <= 5, "{compression:?}: colour ramp off by {line_err}");
assert!(plane_err <= 10, "{compression:?}: 2D gradient off by {plane_err}");
assert!(p >= 38.0, "{compression:?}: 2D gradient PSNR {p:.1} dB");
match compression {
DdsCompression::Bc1 => assert!(line_back.data().chunks(4).zip(line.data().chunks(4)).all(|(b, i)| b[3] == if i[3] < 128 { 0 } else { 255 })),
// Four bits: within half a step of 17.
DdsCompression::Bc2 => assert!(alpha <= 8, "BC2 alpha off by {alpha}"),
// Eight values across a block's few levels of alpha.
_ => assert!(alpha <= 2, "BC3 alpha off by {alpha}"),
}
}
}
#[test]
fn solid_and_cutout_textures_keep_their_values() {
// RGB565-exact colours and BC2's 4-bit alpha steps come back exactly.
let solid = Image::from_u8(6, 5, ChannelLayout::Rgba, [255, 0, 255, 170].repeat(30)).unwrap();
for compression in BLOCKS {
let back = decode(&encode(&solid, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
let want = if compression == DdsCompression::Bc1 { [255, 0, 255, 255] } else { [255, 0, 255, 170] };
assert!(back.data().chunks(4).all(|p| p == want), "{compression:?}: {:?}", &back.data()[..4]);
}
// A cutout: BC1's transparent texels decode as transparent black, the rest stay opaque.
let cutout: Vec<u8> = (0..36).flat_map(|i| if (i / 6 + i % 6) % 2 == 0 { [200, 40, 40, 255] } else { [10, 10, 10, 0] }).collect();
let cutout = Image::from_u8(6, 6, ChannelLayout::Rgba, cutout).unwrap();
for compression in BLOCKS {
let back = decode(&encode(&cutout, Format::Dds, &opts(compression, false)).unwrap()).unwrap();
assert_eq!(max_diff_channel(&cutout, &back, 3), 0, "{compression:?}");
if compression == DdsCompression::Bc1 {
assert!(back.data().chunks(4).filter(|p| p[3] == 0).all(|p| p == [0, 0, 0, 0]));
}
}
}
#[test]
fn mipmaps_write_the_full_chain_box_filtered() {
// 16×8 halves to 8×4, 4×2, 2×1 and 1×1: five levels.
let img = ramp(16, 8, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!(le32(&b, 8), REQUIRED | DDSD_PITCH | DDSD_MIPMAPCOUNT);
assert_eq!(le32(&b, 28), 5);
assert_eq!(le32(&b, 108), DDSCAPS_TEXTURE | DDSCAPS_COMPLEX | DDSCAPS_MIPMAP);
assert_eq!(b.len(), 128 + 4 * (16 * 8 + 8 * 4 + 4 * 2 + 2 + 1));
assert_eq!(decode(&b).unwrap().data(), img.data(), "the reader still reads the top level");
let mut above = img.clone();
for i in 1..5 {
let got = level(&b, i);
assert_eq!(got.dimensions(), ((16 >> i).max(1), (8 >> i).max(1)));
let want = box_filter(&above);
assert_eq!(got.data(), want.data(), "level {i}");
above = got;
}
// 1×1 has a single level.
let one = encode(&ramp(1, 1, ChannelLayout::Rgba), Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!((le32(&one, 28), le32(&one, 8) & DDSD_MIPMAPCOUNT), (1, 0));
}
/// The documented filter, written independently: each pixel averages the 2×2 pixels it covers,
/// and the last column and row of an odd-sized level take in the leftover pixel too.
fn box_filter(src: &Image) -> Image {
let (w, h) = (src.width() as usize, src.height() as usize);
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
let ch = src.layout().channels();
let span = |i: usize, n: usize, size: usize| (2 * i, if i + 1 == n { size } else { 2 * i + 2 });
let mut out = Vec::new();
for y in 0..nh {
for x in 0..nw {
let ((x0, x1), (y0, y1)) = (span(x, nw, w), span(y, nh, h));
for c in 0..ch {
let values: Vec<u32> =
(y0..y1).flat_map(|sy| (x0..x1).map(move |sx| (sy, sx))).map(|(sy, sx)| u32::from(src.data()[(sy * w + sx) * ch + c])).collect();
let n = values.len() as u32;
out.push(((values.iter().sum::<u32>() + n / 2) / n) as u8);
}
}
}
Image::from_u8(nw as u32, nh as u32, src.layout(), out).unwrap()
}
#[test]
fn odd_sized_mip_chains_keep_every_pixel() {
// 5×3 → 2×1 → 1×1. The 2×1 level's right pixel averages columns 2–4 of all three rows.
let img = ramp(5, 3, ChannelLayout::Gray);
let b = encode(&img, Format::Dds, &opts(DdsCompression::None, true)).unwrap();
assert_eq!(le32(&b, 28), 3);
assert_eq!(b.len(), 128 + 5 * 3 + 2 + 1);
let second = level(&b, 1);
assert_eq!(second.data(), box_filter(&img).data());
let px = |x: usize, y: usize| u32::from(img.data()[y * 5 + x]);
let right: u32 = (0..3).flat_map(|y| (2..5).map(move |x| (x, y))).map(|(x, y)| px(x, y)).sum();
assert_eq!(u32::from(second.data()[1]), (right + 4) / 9);
}
#[test]
fn block_compressed_mip_chains_have_a_block_per_small_level() {
for compression in BLOCKS {
let block = if compression == DdsCompression::Bc1 { 8 } else { 16 };
let img = ramp(12, 6, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, true)).unwrap();
// 12×6, 6×3, 3×1, 1×1: 3×2, 2×1, 1 and 1 blocks.
assert_eq!(le32(&b, 28), 4);
assert_eq!(le32(&b, 8), REQUIRED | DDSD_LINEARSIZE | DDSD_MIPMAPCOUNT);
assert_eq!(le32(&b, 20), 6 * block);
assert_eq!(b.len(), 128 + (6 + 2 + 1 + 1) * block as usize);
for i in 0..4 {
let got = level(&b, i);
assert_eq!(got.dimensions(), ((12 >> i).max(1), (6 >> i).max(1)), "{compression:?} level {i}");
}
}
}
#[test]
fn strips_and_tiny_textures_encode_with_every_option() {
for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 3), (3, 2)] {
for compression in DdsCompression::ALL {
for mipmaps in [false, true] {
let img = ramp(w, h, ChannelLayout::Rgba);
let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap_or_else(|e| panic!("{w}x{h} {compression:?} {mipmaps}: {e}"));
assert_eq!(decode(&b).unwrap().dimensions(), (w, h));
}
}
}
}
#[test]
fn fidelity_warnings_follow_the_compression() {
let translucent = ramp(8, 8, ChannelLayout::Rgba);
let warns = |c| fidelity_warnings_with(&translucent, Format::Dds, &opts(c, false));
assert!(warns(DdsCompression::None).is_empty(), "uncompressed RGBA loses nothing");
for c in BLOCKS {
assert!(warns(c).contains(&FidelityWarning::LossyCompression), "{c:?}");
}
assert!(warns(DdsCompression::Bc1).contains(&FidelityWarning::AlphaBinarized));
assert!(!warns(DdsCompression::Bc3).contains(&FidelityWarning::AlphaBinarized));
}
#[test]
fn compression_names_round_trip() {
for c in DdsCompression::ALL {
assert_eq!(DdsCompression::from_name(c.name()), Some(c));
}
assert_eq!(DdsCompression::from_name("DXT5"), Some(DdsCompression::Bc3));
assert_eq!(DdsCompression::from_name("dxt1"), Some(DdsCompression::Bc1));
for bad in ["", "bc7", "dxt2", "uncompressed "] {
assert_eq!(DdsCompression::from_name(bad), None, "{bad:?}");
}
}
/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release:
///
/// ```sh
/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture
/// ```
#[test]
#[ignore]
fn dds_encode_timings_on_30_mp() {
let (w, h) = (6336u32, 4752u32);
let mut s = 0x1234_5678u32;
let mut data = Vec::with_capacity(w as usize * h as usize * 4);
for y in 0..h {
for x in 0..w {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
data.extend([(x / 25 + y / 19) as u8, (x / 31) as u8 ^ (s as u8 & 3), (y / 23) as u8, (255 - x / 25) as u8]);
}
}
let img = Image::from_u8(w, h, ChannelLayout::Rgba, data).unwrap();
for compression in DdsCompression::ALL {
for mipmaps in [false, true] {
let t = std::time::Instant::now();
let b = encode(&img, Format::Dds, &opts(compression, mipmaps)).unwrap();
eprintln!("{compression:?} mipmaps={mipmaps}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1e3, b.len() >> 20);
}
}
}
+107 -1
View File
@@ -8,7 +8,9 @@ use exr::prelude::{
WritableImage,
};
use photocraft_codecs::f16;
use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info};
use photocraft_codecs::{
ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size,
};
fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec<f32>)]) -> Layer<AnyChannels<FlatSamples>> {
let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len());
@@ -226,3 +228,107 @@ fn decoding_one_part_does_not_decode_the_others() {
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}
fn channel_group_file(channels: &[(&str, f32)]) -> Vec<u8> {
let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect();
write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)])
}
fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) {
assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8);
assert_eq!(probe_size(bytes).unwrap(), (2, 2));
for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] {
assert_eq!(img.layout(), layout);
assert_eq!(img.to_normalized(), expected.repeat(4));
}
}
#[test]
fn flat_decode_keeps_rgb_channels_in_one_group() {
// A partial group sorts before the nested beauty layer, but cannot donate its R.
let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
}
#[test]
fn flat_decode_does_not_borrow_another_groups_alpha() {
for alpha in ["A", "aaa.A", "beauty.other.A"] {
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]);
assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]);
}
}
#[test]
fn flat_decode_rejects_rgb_split_between_groups() {
for channels in [
[("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)],
[("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)],
[(".R", 0.25), ("G", 0.5), ("B", 2.0)],
] {
let bytes = channel_group_file(&channels);
assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0);
assert!(probe_size(&bytes).is_err());
assert!(decode(&bytes).is_err());
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
}
}
#[test]
fn auto_pick_and_probe_skip_incoherent_rgb_parts() {
let bytes = write_image(vec![
layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]),
layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]),
]);
let parts = exr_info(&bytes, &Limits::default()).unwrap();
assert_eq!(parts.iter().map(|part| part.color_rank()).collect::<Vec<_>>(), [0, 2]);
assert_eq!(probe_size(&bytes).unwrap(), (4, 4));
let img = decode(&bytes).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA));
assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16));
}
#[test]
fn flat_decode_prefers_the_highest_ranked_complete_group() {
let bytes =
channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]);
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]);
assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]);
}
#[test]
fn flat_decode_breaks_group_ties_by_prefix() {
let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
// Even a prefix that sorts before the unprefixed channel names loses a rank tie.
let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
}
#[test]
fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() {
for prefix in ["", "beauty."] {
let y = format!("{prefix}Y");
let a = format!("{prefix}A");
assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]);
assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]);
}
for name in ["depth.Z", "A", "beauty.A"] {
// A lone alpha is one grayscale channel, not the same samples duplicated as YA.
assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]);
}
}
#[test]
fn half_float_group_does_not_use_unrelated_float_channels() {
let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]);
for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] {
layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4])));
}
layer.channel_data = AnyChannels::sort(layer.channel_data.list);
let bytes = write_image(vec![layer]);
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16);
}
+3
View File
@@ -137,6 +137,9 @@ mod fits {
mod dpx {
cases!(Format::Dpx);
}
mod dds {
cases!(Format::Dds);
}
mod qoi {
cases!(Format::Qoi);
}
Loaded 100 of 554 files, more files were not shown because too many files have changed in this diff. Show more