Compare commits

...
Author SHA1 Message Date
Claude df0e858198 Merge main 2026-10-09 14:18:22 +00:00
Claude 67cf1ea6e4 Merge 1734-fxdrag 2026-10-09 14:08:10 +00:00
Claude 1f0f8fc13e Merge 1706-corpus 2026-10-09 14:08:10 +00:00
Claude a04469a099 Merge 1462-labmix 2026-10-09 14:08:10 +00:00
Claude 9cc8e17be4 Merge 1636-pixls 2026-10-09 14:08:09 +00:00
Claude 0760e1c70c Merge 1745-eyedropper 2026-10-09 14:08:09 +00:00
Claude db09f7f575 Merge p1718 2026-10-09 14:08:09 +00:00
Claude 6097ef8102 Merge p1728 2026-10-09 14:08:09 +00:00
Claude c7296f3d7b Merge p1720 2026-10-09 14:08:09 +00:00
Claude 1c5976843e Merge p1785 2026-10-09 14:08:09 +00:00
Claude a61d8b305c Merge p1743 2026-10-09 14:08:08 +00:00
Claude 744a0c7c4e Merge p1744 2026-10-09 14:08:08 +00:00
Claude ac9c7b057b Merge main into #1745 (catalogs: keep both blocks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 14:08:06 +00:00
Claude 42e60e3602 Merge #1636 into #1706: keep the pixls opt-in, fetch_all takes update
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:56:23 +00:00
Claude beb80bc5dc Carry #1636: document test-corpus --pixls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:56:22 +00:00
Claude 819651f3c0 Merge commit 'refs/tmp/p1745' into community/1745-eyedropper
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
2026-10-09 13:55:40 +00:00
Brandon Thomas 74112064cc Merge main; de: Photoshop's German eyedropper terms (Aufnahmebereich, 1 Pixel, 3 x 3 Pixel Durchschnitt) 2026-10-09 09:32:53 -04:00
Brandon ThomasandClaude Opus 5.5 2e41caf897 ATTRIBUTION: keep the PngSuite row and one pixls row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:29:58 -04:00
Brandon Thomas 4b7db4088b Merge remote-tracking branch 'origin/main' into pr1636ff 2026-10-09 09:29:34 -04:00
Claude 0b958be52a Merge main into #1734 (effect-row eyes from #1664 kept beside the fx drag)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:29:19 +00:00
Claude a94dd19d79 Merge main into #1745 (German catalog)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 13:29:18 +00:00
Claude 8cdea53ced Carry #1745: Current & Below keeps an active group's own contents
Document::walk lists a group before its children, so hiding everything after
the active layer hid a group's own layers and sampled transparent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 11:50:37 +00:00
Claude cc8d3c3f77 Merge main into #1462; in_blend_space restores LAB_MIX and HDR instead of clearing them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 11:49:32 +00:00
Juan Manuel Verges SolanasandClaude Opus 5.5 0103edfdfd inspect: report each smart object's source
document.inspect (and photocraft-cli info) now gives every smart object a
smartSource: embedded with its file name, or linked with a file path or,
for a PSD placed layer, the Idnt uuid its duplicates share. A script can
then tell duplicates of one placed file from independent contents, e.g.
to check that every location in a mockup template has its own art.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:17:15 +00:00
Elvis33LE 3bfaef668c pixls: usage and status text, per-file licence links, dedup, --changed for raw
The xtask usage line and the corpus status listing name --pixls and
corpus/pixls; the attribution row links each of the seven files' database
entries so the licence can be checked per file; -p raw no longer pushes
the crate into the list twice; and test-corpus --changed runs (and
fetches) the raw corpus when a file under crates/raw/ changed, matching
its CRITICAL entry.
2026-10-09 13:08:16 +02:00
Elvis33LE 5d0800df7a pixls corpus: in-house sha256 and pinned.rs, opt-in like the affinity corpus
The fetch lives in pinned.rs now, reusing its manifest discipline and the
xtask sha256 module (the sha2 dependency is gone). --pixls is opt-in: not
part of --all or the CI corpus job (CORPUS_CRATES no longer includes
photocraft-raw; test-corpus --pixls fetches and adds it), matching how the
Affinity corpus is handled. The usage line names the flag, and the
attribution row links each sample's folder in the database so its licence
can be checked per file.
2026-10-09 13:08:16 +02:00
Elvis33LE 6ce21609a2 raw corpus: real camera files from raw.pixls.us, oracle-tested
Seven public-domain files (raw.pixls.us), one per decode path: Canon
PowerShot SX50 HS as CR2 and as its DNG conversion, Nikon D3 uncompressed
NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2 (10480x7794,
crop 10368x7776), Olympus E-1 with a GRBG CFA, and the Olympus E-5 as
the documented known-unsupported packed-ORF case (still previews).

xtask grows --pixls (single-file downloads, the server redirects
/data/ to /download/), a sha256-verified manifest and a --update-manifest
path; photocraft-raw gains the corpus feature and oracle tests: format,
make/model, sensor size, CFA, black/white levels and the crop are exact,
developing yields the crop in 16-bit RGB, deterministic, with sane
range coverage.

Two real findings already documented by the tests: the PowerShot CR2 and
the RX0 ARW carry no as-shot white balance we read, so the grey-world
estimate brightens those scenes (the same camera's DNG develops blacks
to ~0). Reading the Canon and Sony maker-note WB tags is the follow-up.
2026-10-09 13:06:05 +02:00
Claude Opus 5.5 acc27e0fd0 Merge remote-tracking branch 'origin/main' into fix/1112-lab-mix-restore
# Conflicts:
#	crates/compose/src/lib.rs
#	crates/compose/src/tests.rs
2026-10-09 06:57:07 -04:00
Gert Pellin 65f30dd9e3 feat(eyedropper): Sample Size, Sample layers and Show Sampling Ring (#1649)
The Eyedropper options bar now has Photoshop's Sample Size (Point Sample,
3 by 3 ... 101 by 101 Average), Sample (Current Layer, Current & Below,
All Layers, All Layers No Adjustments, Current & Below No Adjustments)
and Show Sampling Ring.

- engine: new `document.sampleColor {x, y, size, sampleLayer}`
  (sample_cmds.rs). Averages the square centred on the pixel, clipped to
  the canvas, alpha-weighted, in the document's colour model (Gray, Lab;
  CMYK documents use their composite RGB, since the profile round trip
  is not exact) and rounded to the document's bit depth.
- ui: eyedropper_ui.rs options bar; state.rs ToolOptions
  eyedropper_size / eyedropper_sample / eyedropper_ring (serde). The
  size is shared with the painting tools' Alt-click eyedropper, the
  Curves / Color Picker eyedroppers and the Info panel readout.
- control: ui.set eyedropperSampleSize / eyedropperSample /
  eyedropperRing; documented in control-protocol.md and the MCP schema.
- i18n: new strings in all 14 catalogs (plus the missing Russian hint
  the tl! scan had not reached).
- scorecard: TOOL-1649-1.
2026-10-09 12:28:14 +02:00
Gert Pellin 4a360c3780 fix(ui): one Cmd+V pastes one layer (#1638)
On macOS AppKit runs Edit > Paste's key equivalent and takes the
key-down, but winit still delivers the key-up (it forwards key-ups
while Cmd is held). The native menu fed egui a press plus a made-up
release, and clipboard_keys treats a Cmd+V release without a paste
event as an image paste (egui-winit drops Cmd+V when the clipboard
holds no text). So one Cmd+V pasted three times: the menu's press,
its synthetic release, and winit's real release.

- native_menu: a key equivalent adds only its press, inserted ahead of
  its key-up when that is already queued; winit delivers the release.
- clipboard_keys: a Cmd+V key press that reached egui marks the paste
  as seen, so its release doesn't paste again; any V/Insert release
  clears the mark (releasing Ctrl before V left it stale and swallowed
  the next image paste on Linux/Windows).
2026-10-09 12:27:48 +02:00
Gert Pellin 98e48fc77b fix(codecs): read the resolution from EXIF, XMP and Photoshop APP13; write it consistently (#1691)
A camera JPEG (e.g. Nikon D3200) records its resolution only in EXIF IFD0, so it
opened at 72 ppi instead of 300, and saving wrote JFIF 72 next to the source's
EXIF 300.

Reading: a JPEG takes the first usable of APP13 Photoshop ResolutionInfo
(0x03ED), XMP tiff:X/YResolution, EXIF IFD0 X/YResolution + ResolutionUnit
(2 inch, also when absent; 3 cm), and only then the JFIF density. That is the
order Photoshop resolves them in; JFIF comes last because cameras write EXIF
only while many tools write a default JFIF 72. Other formats (WebP, HEIF, PNG
without pHYs, ...) fall back to XMP then EXIF when they have no resolution of
their own. All parsing is bounds-checked; malformed data reads as no
resolution.

Writing: JPEG, PNG, WebP, TIFF (XMP), PSD and Save for Web JPEG rewrite the
EXIF/XMP resolution to the image's: RATIONAL values in place, ResolutionUnit 2,
and an entry that can't be rewritten safely is removed from IFD0 without moving
any other data.
2026-10-09 12:27:48 +02:00
MarekSpodBiedry 6ba1419286 Layers panel fx drag test: measure the drop target before the mutable drag borrow (#1719) 2026-10-09 12:18:32 +02:00
7a3347b9a0 Wheel zoom: a notch applies on the frame it arrives (from #1490)
Folded in from @Jeremy8776's #1490, as agreed in review: zooming reads this frame's wheel
events (`Input::raw`) instead of egui's smoothed delta, so each notch is one ×1.1 step on the
frame it arrives, with no easing over the next frames (his frame-by-frame recordings of
Photoshop). The smoothed tail neither zooms nor turns into a pan when Alt is released; panning
still uses the smoothed delta. Zoom with Scroll Wheel uses the same ×1.1 notch (assumed, not
measured).

The Filter Gallery's and Liquify's own preview zooms keep their 3200 % caps, now with a comment
saying why: they aren't the canvas.

Co-authored-by: Jeremy <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 12:11:33 +02:00
UnemarsandClaude Opus 5.5 92cef00c2f Zoom: Print Size, 200 % and Fit Layers on Screen clamp through zoom_levels too
Review of #1718: `view.twoHundredPercent` / `view.printSize` and `view.fitLayersOnScreen` still
clamped to 1-6400 % on their own. They now use `zoom_levels::clamp` like every other zoom path:
Print Size at 1 ppi gives 7200 % (it was cut to 6400 %), and fitting a 1 px layer stops at
12800 %.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 12:01:51 +02:00
MarekSpodBiedry b692ed451c Layers panel: drag layer effects onto another layer to move them, Alt-drag copies (#1719)
Effect rows only took clicks and the fx badge was part of the layer row, so
dragging an effect or the fx badge picked up nothing (or the whole layer).

As in Photoshop, dragging the fx badge or the "Effects" row onto another
layer moves all its effects there (replacing that layer's style, like Paste
Layer Style), dragging one effect row moves just that effect (replacing an
effect of the same kind), and holding Alt on release copies instead. The
badge takes the drag the way the eye does, so clicks still select the row.
The drop is one history step through a new engine command,
layer.layerStyle.transferEffects (no menu entry, like Show All Effects).

Tests: the engine command (copy, move one, move all, undo, bad params) and a
Layers panel test that Alt-drags an effect row and drags the fx badge.
2026-10-09 11:54:19 +02:00
MarekSpodBiedry fee28f3a1b xtask corpus: pass the pinned corpus without an explicit deref (clippy) (#948) 2026-10-09 11:34:11 +02:00
MarekSpodBiedry 52b9d2453f Patch Tool: say to drag the selection after the lasso and after a click that moves nothing (#1715)
The Patch and Content-Aware Move tools work in two steps: lasso the
area, then drag the selection. The first step only leaves a selection,
and a press and release inside the selection returns early because the
offset is zero, so neither step said what to do next.

After a lasso made with either tool, and after a drag inside the
selection that moves nothing, the status bar now says "Now drag the
selection onto another area". The string is translated in all 14
catalogs.
2026-10-09 11:19:51 +02:00
buivanvu94 622d6b3233 fix(ui): autoscroll Layers panel when dragging past top or bottom edges 2026-10-09 16:19:48 +07:00
UnemarsandClaude Opus 5.5 c3da650ebb Canvas zoom: Photoshop's range, ladder and ⌥-wheel step; no slide at the limit
Measured on Photoshop 25.4 (exact zoom read by script, view position by screenshot
registration):
- ⌥ + wheel: ×1.1 a notch around the pointer (was ×1.05);
- the zoom runs up to 12800 % and down to where the document's shorter side is 2 px; a wheel
  notch past a limit lands on it exactly and further notches change nothing;
- Zoom In / Zoom Out and a Zoom tool click step along 0.1 … 66.67, 100, 200 … 800, 1200, 1600,
  3200, 6400, 12800 %, from any zoom to the next rung;
- with Overscroll off, an image smaller than the view stays where zooming put it, inside the
  view, instead of being re-centred.

PhotoCraft capped the zoom at 3200 % without meaning to: the status bar's zoom field (and the
Navigator's slider) clamped it and wrote it back every frame. Past 3200 % each ⌥-wheel notch
zoomed around the pointer and the field pulled the zoom back, so the image slid towards the
pointer. All zoom paths now share `zoom_levels` (range, ladder, clamp), and the fields always
include the current zoom.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 11:13:42 +02:00
MarekSpodBiedry a5d92c231c fix(xtask): corpus --all --update-manifest refetches and rewrites every manifest (#948) 2026-10-09 10:42:10 +02:00
Claude Opus 5.5 f1433f4ab2 Compose: restore the Lab-mix flag instead of clearing it after a tile (#1112)
Rendering set the thread-local LAB_MIX to the document's value around each
tile, then set it to false. A nested rayon job (a gradient fill, say) can
let the same thread render another tile in the middle of an outer one;
clearing the flag there made the rest of the outer Lab tile mix in sRGB.
The issue's reproduction (a 4096 px Lab document with a gradient fill and
two 50 % fills, 60 renders, RAYON_NUM_THREADS=4) showed whole 256 px tiles
differing between identical renders.

psblend::with_lab_mix sets the flag, runs the closure, and restores the
previous value with a drop guard, as with_cmyk_space does for the CMYK
space. The three set/clear pairs in render_tiled_with use it.

The new test sets the flag, renders Lab and RGB documents on the single-
and multi-tile paths, and checks the flag is what it was. It failed before
the fix. The issue's reproduction, run twice each way on macOS with four
threads: before, 6 and 2 of 60 renders had one whole tile wrong (65536 px);
after, 0 of 60 both times.
2026-10-08 18:27:48 -04:00
75 changed files with 3366 additions and 232 deletions

No files matched your search

+1
View File
@@ -87,5 +87,6 @@ next to the files:
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (21 files, 2.4 MB) | Public Affinity documents, unmodified: `vector-art/` (9), `afdesignload/` (9), `jac21/` (2) and `asset-store-template/` (1), each with its upstream licence next to it | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo) | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate) | CC0-1.0 (vector-art); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
+2 -1
View File
@@ -134,7 +134,8 @@ fn main() {
.iter()
.filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.command)) } else { None })
.collect();
assert_eq!(keys, [(egui::Key::R, true, true), (egui::Key::R, false, true)], "the key press, then its release");
// AppKit took only the key-down; winit delivers the key-up itself (#1638).
assert_eq!(keys, [(egui::Key::R, true, true)], "the key press");
frame(&mut app, raw);
let ran: Vec<String> = photocraft_ui_egui::shortcut_dispatch::take_log(&ctx).into_iter().map(|(id, _)| id).collect();
assert_eq!(ran, ["view.rulers"], "⌘R ran Rulers, whichever ⌘R item AppKit matched");
+3 -1
View File
@@ -200,7 +200,9 @@ pub struct UiSetParams {
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic), brushSection, brushTab, brushesView, brushPicker ([x, y] opens the
/// Brush Preset picker there, null closes it), brushPickerView, brushSize, gradientBlendMode
/// (a blend mode name, for the Gradient tool), gradientClassic (bool). Other fields are an
/// (a blend mode name, for the Gradient tool), gradientClassic (bool), eyedropperSampleSize
/// ("point" or 1, 3, 5, 11, 31, 51, 101), eyedropperSample (current, currentAndBelow, all,
/// allNoAdjustments, currentAndBelowNoAdjustments), eyedropperRing (bool). Other fields are an
/// error.
pub fields: Value,
}
+9
View File
@@ -106,6 +106,15 @@ the same.
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
`Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in
release).
* **Resolution** (#1691, module `resolution`): a file without a resolution of its own format
(no PNG `pHYs`, a WebP, HEIF, …) takes it from its XMP `tiff:XResolution` / `YResolution` /
`ResolutionUnit`, else from EXIF IFD0 (RATIONAL X/YResolution, unit 2 inch — the default when
absent — or 3 cm; unit 1 or anything malformed counts as no resolution). A JPEG uses, like
Photoshop, the first usable of: APP13 Photoshop ResolutionInfo (0x03ED), XMP, EXIF, and only
then the JFIF density (cameras write EXIF only, many tools write a default JFIF 72). On export
the EXIF and XMP resolution are rewritten to the image's (`export_exif`, `export_xmp`): the
RATIONAL values in place, ResolutionUnit set to 2, an entry that can't be rewritten removed
from IFD0 without moving any other data. IFD1 (the thumbnail's) is left as it is.
* **JPEG**
* 8-bit only. Neither decoder backend supports 12-bit.
* A file cut off inside its image data decodes leniently (a baseline JPEG's missing rows come
+30 -11
View File
@@ -1,7 +1,14 @@
//! JPEG: decode via `zune-jpeg` (gray, YCbCr, CMYK, YCCK), encode via
//! `jpeg-encoder` (gray, RGB, CMYK). Metadata (APP0 JFIF density, APP1
//! EXIF/XMP, APP2 multi-segment ICC, APP14 Adobe) is parsed by our own marker
//! scanner so behaviour does not depend on decoder internals.
//! EXIF/XMP, APP2 multi-segment ICC, APP13 Photoshop resources, APP14 Adobe) is
//! parsed by our own marker scanner so behaviour does not depend on decoder internals.
//!
//! **Resolution** (#1691) can be recorded four times; like Photoshop the first usable one of
//! these wins: the APP13 Photoshop ResolutionInfo, the XMP `tiff:` resolution, the EXIF IFD0
//! resolution, and last the JFIF density (a unit-0 JFIF density is only an aspect ratio and
//! never counts). Cameras record their resolution in EXIF only, and many tools write a default
//! 72 into JFIF, so JFIF can't outrank EXIF (see [`crate::resolution`]). On export the JFIF
//! density, the EXIF and the XMP resolution are all written as the image's resolution.
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
@@ -12,12 +19,13 @@ use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits};
use crate::orientation::{upright_exif, upright_xmp};
use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
const F: Format = Format::Jpeg;
const EXIF_HEADER: &[u8] = b"Exif\0\0";
const XMP_HEADER: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
const ICC_HEADER: &[u8] = b"ICC_PROFILE\0";
const PHOTOSHOP_HEADER: &[u8] = b"Photoshop 3.0\0";
#[derive(Default, Debug)]
pub(crate) struct JpegMeta {
@@ -34,6 +42,9 @@ pub(crate) struct JpegMeta {
pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
let mut m = JpegMeta::default();
let mut icc_parts: Vec<(u8, u8, &[u8])> = Vec::new();
// Photoshop splits a large resource block over consecutive APP13 segments.
let mut photoshop: Vec<u8> = Vec::new();
let mut jfif_dpi = None;
let mut i = 2;
while i + 4 <= b.len() {
if b[i] != 0xFF {
@@ -62,7 +73,7 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
let x = u16::from_be_bytes([seg[8], seg[9]]) as f32;
let y = u16::from_be_bytes([seg[10], seg[11]]) as f32;
if x > 0.0 && y > 0.0 {
m.dpi = match unit {
jfif_dpi = match unit {
1 => Some((x, y)),
2 => Some((x * 2.54, y * 2.54)),
_ => None,
@@ -75,6 +86,9 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
0xE1 if seg.starts_with(XMP_HEADER) && m.xmp.is_none() => {
m.xmp = std::str::from_utf8(&seg[XMP_HEADER.len()..]).ok().map(|s| s.trim_end_matches('\0').to_owned());
}
0xED if seg.starts_with(PHOTOSHOP_HEADER) && photoshop.len() < 1 << 24 => {
photoshop.extend_from_slice(&seg[PHOTOSHOP_HEADER.len()..]);
}
0xE2 if seg.len() >= 14 && seg.starts_with(ICC_HEADER) => {
icc_parts.push((seg[12], seg[13], &seg[14..]));
}
@@ -90,6 +104,10 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
}
i += 2 + len;
}
m.dpi = photoshop_resolution(&photoshop)
.or_else(|| m.xmp.as_deref().and_then(xmp_resolution))
.or_else(|| m.exif.as_deref().and_then(exif_resolution))
.or(jfif_dpi);
if !icc_parts.is_empty() {
icc_parts.sort_by_key(|p| p.0);
let total = icc_parts[0].1 as usize;
@@ -250,10 +268,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
}
// Metadata too large for its one segment is left out (and reported by the fidelity
// warnings) rather than failing the whole export.
if let Some(seg) = img.meta.exif.as_deref().and_then(exif_segment) {
if let Some(seg) = img.meta.exif.as_deref().and_then(|x| exif_segment(x, img.meta.dpi)) {
enc.add_app_segment(1, &seg).map_err(e)?;
}
if let Some(seg) = img.meta.xmp.as_deref().and_then(xmp_segment) {
if let Some(seg) = img.meta.xmp.as_deref().and_then(|x| xmp_segment(x, img.meta.dpi)) {
enc.add_app_segment(1, &seg).map_err(e)?;
}
}
@@ -275,16 +293,17 @@ const MAX_SEGMENT: usize = 65533;
const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
pub(crate) fn exif_segment(exif: &[u8]) -> Option<Vec<u8>> {
// The pixels are written as they are shown: never let a viewer rotate them again.
let seg = [EXIF_HEADER, upright_exif(exif).as_ref()].concat();
pub(crate) fn exif_segment(exif: &[u8], dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
// The pixels are written as they are shown: never let a viewer rotate them again; and the
// resolution is the one written to JFIF, not the source's (#1691).
let seg = [EXIF_HEADER, export_exif(exif, dpi).as_ref()].concat();
(seg.len() <= MAX_SEGMENT).then_some(seg)
}
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
/// properties are left out; `None` when it still doesn't fit.
pub(crate) fn xmp_segment(xmp: &str) -> Option<Vec<u8>> {
let xmp = upright_xmp(xmp);
pub(crate) fn xmp_segment(xmp: &str, dpi: Option<(f32, f32)>) -> Option<Vec<u8>> {
let xmp = export_xmp(xmp, dpi);
let seg = |x: &str| [XMP_HEADER, x.as_bytes()].concat();
if XMP_HEADER.len() + xmp.len() <= MAX_SEGMENT {
return Some(seg(&xmp));
+4 -2
View File
@@ -263,7 +263,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
// The pixels are written as they are shown: never let a viewer rotate them again.
info.exif_metadata = Some(crate::orientation::upright_exif(exif).into_owned().into());
info.exif_metadata = Some(crate::resolution::export_exif(exif, img.meta.dpi).into_owned().into());
}
if let Some((x, y)) = img.meta.dpi
&& x > 0.0
@@ -296,7 +296,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
res.map_err(|e| CodecError::encode(F, e))?;
}
if let Some(xmp) = &img.meta.xmp {
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::orientation::upright_xmp(xmp).into_owned()).map_err(|e| CodecError::encode(F, e))?;
encoder
.add_itxt_chunk(XMP_KEYWORD.into(), crate::resolution::export_xmp(xmp, img.meta.dpi).into_owned())
.map_err(|e| CodecError::encode(F, e))?;
}
}
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
+1 -1
View File
@@ -1129,7 +1129,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
Vec::new()
};
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(crate::orientation::upright_xmp) } else { None };
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
let tags = TagSet {
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
xmp: xmp.as_deref().map(str::as_bytes),
+4 -4
View File
@@ -60,10 +60,10 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
// The pixels are written as they are shown: never let a viewer rotate them again.
enc.set_exif_metadata(crate::orientation::upright_exif(exif).into_owned());
enc.set_exif_metadata(crate::resolution::export_exif(exif, img.meta.dpi).into_owned());
}
if let Some(xmp) = &img.meta.xmp {
enc.set_xmp_metadata(crate::orientation::upright_xmp(xmp).as_bytes().to_vec());
enc.set_xmp_metadata(crate::resolution::export_xmp(xmp, img.meta.dpi).as_bytes().to_vec());
}
}
enc.encode(img.data(), img.width(), img.height(), ct).map_err(|e| CodecError::encode(F, e))?;
@@ -92,8 +92,8 @@ fn encode_lossy(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError
None
};
let icc = if opts.embed_icc { img.icc.as_deref() } else { None };
let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(crate::orientation::upright_exif) } else { None };
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(crate::orientation::upright_xmp) } else { None };
let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(|e| crate::resolution::export_exif(e, img.meta.dpi)) } else { None };
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
Ok(riff(w, h, &frame, alpha.as_deref(), icc, exif.as_deref(), xmp.as_deref().map(str::as_bytes)))
}
+2 -2
View File
@@ -227,10 +227,10 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
w.push(W::XmpDropped);
}
if format == Format::Jpeg {
if let Some(exif) = m.exif.as_deref().filter(|e| crate::codecs::jpeg::exif_segment(e).is_none()) {
if let Some(exif) = m.exif.as_deref().filter(|e| crate::codecs::jpeg::exif_segment(e, None).is_none()) {
w.push(W::MetadataTooLarge { what: "EXIF", bytes: exif.len() });
}
if let Some(xmp) = m.xmp.as_deref().filter(|x| crate::codecs::jpeg::xmp_segment(x).is_none()) {
if let Some(xmp) = m.xmp.as_deref().filter(|x| crate::codecs::jpeg::xmp_segment(x, None).is_none()) {
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
}
}
+9
View File
@@ -20,6 +20,7 @@ mod format;
mod image;
mod options;
pub mod orientation;
pub mod resolution;
pub mod web;
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
@@ -31,6 +32,7 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, Metadata, SampleType};
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, 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 half::f16;
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
@@ -68,6 +70,13 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
}?;
// Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the
// XMP or EXIF one counts, like Photoshop: a WebP, HEIF or PNG from a camera or phone
// opens at its recorded resolution, not at the 72 ppi default.
let mut img = img;
if img.meta.dpi.is_none() && format != Format::Tiff {
img.meta.dpi = resolution::from_metadata(img.meta.exif.as_deref(), img.meta.xmp.as_deref());
}
// Turn the pixels upright, like Photoshop: a TIFF records it in the decoded page's own
// directory, the others in their EXIF block. The metadata is rewritten to Orientation = 1
// on the way. HEIF keeps it in its container, and its decoder has already applied it.
+6 -6
View File
@@ -36,13 +36,13 @@ const BIG_HEADER: usize = 16;
/// Byte order of a TIFF structure.
#[derive(Clone, Copy)]
enum Order {
pub(crate) enum Order {
Little,
Big,
}
impl Order {
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
pub(crate) fn u16(self, b: &[u8], at: usize) -> Option<u16> {
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(match self {
Order::Little => u16::from_le_bytes(s),
@@ -50,7 +50,7 @@ impl Order {
})
}
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
pub(crate) fn u32(self, b: &[u8], at: usize) -> Option<u32> {
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(match self {
Order::Little => u32::from_le_bytes(s),
@@ -58,7 +58,7 @@ impl Order {
})
}
fn u64(self, b: &[u8], at: usize) -> Option<u64> {
pub(crate) fn u64(self, b: &[u8], at: usize) -> Option<u64> {
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
Some(match self {
Order::Little => u64::from_le_bytes(s),
@@ -68,7 +68,7 @@ impl Order {
}
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
fn tiff_body(b: &[u8]) -> &[u8] {
pub(crate) fn tiff_body(b: &[u8]) -> &[u8] {
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
}
@@ -76,7 +76,7 @@ fn tiff_body(b: &[u8]) -> &[u8] {
///
/// The full table and next-IFD pointer must be present, and IFD0 must start
/// after the corresponding TIFF or BigTIFF header.
fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
pub(crate) fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
let (order, big_tiff) = match b.get(0..4)? {
[b'I', b'I', 42, 0] => (Order::Little, false),
[b'M', b'M', 0, 42] => (Order::Big, false),
+411
View File
@@ -0,0 +1,411 @@
//! Image resolution (pixels per inch) recorded in metadata blocks: EXIF / TIFF IFD0
//! (XResolution 282, YResolution 283, ResolutionUnit 296), XMP (`tiff:XResolution`,
//! `tiff:YResolution`, `tiff:ResolutionUnit`) and Photoshop's image resource
//! ResolutionInfo (`8BIM` resource 0x03ED); reading them, and rewriting the EXIF and XMP
//! copies on export so they agree with the resolution the file is written at.
//!
//! Implemented from TIFF 6.0 (section 8, "Baseline fields": XResolution / YResolution are
//! RATIONAL, ResolutionUnit is SHORT, 1 = no absolute unit, 2 = inch, 3 = centimetre,
//! default 2), EXIF 2.3 (same tags in the 0th IFD, same default) and the Adobe Photoshop File
//! Formats Specification (ResolutionInfo: Fixed 16.16 hRes in pixels per inch, then the
//! display units; the vertical resolution likewise).
//!
//! **Precedence** when a file carries several, as Photoshop resolves them: Photoshop's own
//! ResolutionInfo, then the XMP `tiff:` properties, then EXIF, and only then the format's own
//! density field when it is the generic JFIF one (see the JPEG decoder). JFIF density is last
//! because cameras record the real resolution in EXIF while many tools and libraries write a
//! default JFIF density of 72 (or a bare aspect ratio) without knowing it; Photoshop shows a
//! camera JPEG at its EXIF resolution whatever its JFIF header says. Formats whose own field is
//! written deliberately (PNG `pHYs`, TIFF tags) keep it first; the metadata blocks only fill in
//! when it is missing.
//!
//! Everything read here is untrusted: offsets and counts are bounds-checked, both byte orders
//! and BigTIFF-style EXIF are handled, and anything malformed reads as "no resolution" rather
//! than as a wrong one.
use std::borrow::Cow;
use crate::orientation::{Order, ifd0_entries, tiff_body, upright_exif, upright_xmp};
const TAG_X_RESOLUTION: u16 = 282;
const TAG_Y_RESOLUTION: u16 = 283;
const TAG_RESOLUTION_UNIT: u16 = 296;
const TYPE_SHORT: u16 = 3;
const TYPE_RATIONAL: u16 = 5;
const CM_PER_INCH: f64 = 2.54;
/// Photoshop's ResolutionInfo image resource.
const RESOLUTION_INFO: u16 = 0x03ED;
/// Resolutions above this (pixels per inch) are treated as damage, not data.
const MAX_PPI: f64 = 1_000_000.0;
/// Two resolutions this close (pixels per inch) are the same.
const SAME_PPI: f64 = 1e-3;
/// A usable resolution pair, or `None`.
fn ppi_pair(x: f64, y: f64) -> Option<(f32, f32)> {
let ok = |v: f64| v.is_finite() && v > 0.0 && v <= MAX_PPI;
(ok(x) && ok(y)).then_some((x as f32, y as f32))
}
fn same(a: (f32, f32), b: (f32, f32)) -> bool {
(f64::from(a.0) - f64::from(b.0)).abs() < SAME_PPI && (f64::from(a.1) - f64::from(b.1)).abs() < SAME_PPI
}
/// `ppi` as a TIFF RATIONAL: a whole number over 1 when it is one, else over 1000.
fn rational(ppi: f32) -> (u32, u32) {
let p = f64::from(ppi);
if (p - p.round()).abs() < SAME_PPI { (p.round() as u32, 1) } else { ((p * 1000.0).round() as u32, 1000) }
}
/// One of the three resolution entries of IFD0.
#[derive(Clone, Copy)]
struct Field {
/// Offset of the 12- (BigTIFF: 20-) byte entry.
entry: usize,
tag: u16,
ty: u16,
count: u64,
/// Where the value bytes are, when the type is one we read and they lie inside the block.
value: Option<usize>,
}
impl Field {
fn short(&self, order: Order, b: &[u8]) -> Option<u16> {
(self.ty == TYPE_SHORT && self.count == 1).then_some(())?;
order.u16(b, self.value?)
}
fn rational(&self, order: Order, b: &[u8]) -> Option<f64> {
(self.ty == TYPE_RATIONAL && self.count == 1).then_some(())?;
let at = self.value?;
let (n, d) = (order.u32(b, at)?, order.u32(b, at.checked_add(4)?)?);
(d != 0).then(|| f64::from(n) / f64::from(d))
}
}
/// IFD0's resolution entries.
struct Ifd0 {
order: Order,
big: bool,
first: usize,
count: usize,
entry_bytes: usize,
fields: Vec<Field>,
}
impl Ifd0 {
fn parse(b: &[u8]) -> Option<Ifd0> {
let (order, first, count, entry_bytes, big) = ifd0_entries(b)?;
let mut fields = Vec::new();
for i in 0..count {
let e = first.checked_add(i.checked_mul(entry_bytes)?)?;
let tag = order.u16(b, e)?;
if !matches!(tag, TAG_X_RESOLUTION | TAG_Y_RESOLUTION | TAG_RESOLUTION_UNIT) {
continue;
}
let ty = order.u16(b, e.checked_add(2)?)?;
let count = if big { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
let value = value_at(order, b, e, big, ty, count);
fields.push(Field { entry: e, tag, ty, count, value });
}
Some(Ifd0 { order, big, first, count, entry_bytes, fields })
}
fn first_of(&self, tag: u16) -> Option<&Field> {
self.fields.iter().find(|f| f.tag == tag)
}
}
/// Where an entry's SHORT or RATIONAL value bytes are: inline in the entry when they fit,
/// else at the offset it holds. `None` for other types or when they leave the block.
fn value_at(order: Order, b: &[u8], e: usize, big: bool, ty: u16, count: u64) -> Option<usize> {
let size = match ty {
TYPE_SHORT => 2u64,
TYPE_RATIONAL => 8,
_ => return None,
}
.checked_mul(count)?;
let (field, inline) = if big { (e.checked_add(12)?, 8) } else { (e.checked_add(8)?, 4) };
let at = if size <= inline {
field
} else if big {
usize::try_from(order.u64(b, field)?).ok()?
} else {
usize::try_from(order.u32(b, field)?).ok()?
};
(at.checked_add(usize::try_from(size).ok()?)? <= b.len()).then_some(at)
}
/// The resolution (pixels per inch, horizontal and vertical) recorded in IFD0 of an EXIF block
/// (with or without the JPEG `Exif\0\0` prefix) or a TIFF structure.
///
/// Both XResolution and YResolution must be one RATIONAL each with a non-zero denominator.
/// ResolutionUnit 2 (inch, also when the tag is absent) is used as is, 3 (centimetre) is
/// converted; 1 (no absolute unit), any other value or a malformed unit entry gives `None`.
/// The first of duplicated entries wins.
pub fn exif_resolution(exif: &[u8]) -> Option<(f32, f32)> {
let b = tiff_body(exif);
let ifd = Ifd0::parse(b)?;
let unit = match ifd.first_of(TAG_RESOLUTION_UNIT) {
None => 2,
Some(f) => f.short(ifd.order, b)?,
};
let scale = match unit {
2 => 1.0,
3 => CM_PER_INCH,
_ => return None,
};
let x = ifd.first_of(TAG_X_RESOLUTION)?.rational(ifd.order, b)?;
let y = ifd.first_of(TAG_Y_RESOLUTION)?.rational(ifd.order, b)?;
ppi_pair(x * scale, y * scale)
}
/// `exif` with its IFD0 resolution set to `ppi` (pixels per inch), borrowed when there is
/// nothing to change: no resolution entries, already that resolution, or not parseable.
///
/// Every well-formed XResolution / YResolution (one RATIONAL) gets the new value in place and
/// every well-formed ResolutionUnit (one SHORT) becomes 2 (inch); all other bytes are kept.
/// A resolution entry that can't be rewritten safely (another type or count, or a value outside
/// the block) is removed from IFD0 instead, so no stale value survives: the later entries move
/// up, the entry count and next-IFD pointer follow, and the freed bytes at the table's end are
/// zeroed. Nothing else moves, so every offset in the block stays valid.
pub fn exif_with_resolution(exif: &[u8], ppi: (f32, f32)) -> Cow<'_, [u8]> {
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() {
return Cow::Borrowed(exif);
}
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some(ifd) = Ifd0::parse(body) else { return Cow::Borrowed(exif) };
if ifd.fields.is_empty() || exif_resolution(body).is_some_and(|r| same(r, ppi)) {
return Cow::Borrowed(exif);
}
let order = ifd.order;
let mut out = body.to_vec();
let mut removed = Vec::new();
for f in &ifd.fields {
let bytes = match f.tag {
TAG_RESOLUTION_UNIT if f.short(order, body).is_some() => put_u16(order, 2).to_vec(),
TAG_X_RESOLUTION | TAG_Y_RESOLUTION if f.rational(order, body).is_some() => {
let (n, d) = rational(if f.tag == TAG_X_RESOLUTION { ppi.0 } else { ppi.1 });
[put_u32(order, n), put_u32(order, d)].concat()
}
_ => {
removed.push(f.entry);
continue;
}
};
let slot = f.value.and_then(|at| out.get_mut(at..at.checked_add(bytes.len())?));
match slot {
Some(slot) => slot.copy_from_slice(&bytes),
None => removed.push(f.entry),
}
}
if !removed.is_empty() {
remove_entries(&mut out, &ifd, &removed);
}
let mut full = exif.get(..prefix).unwrap_or_default().to_vec();
full.extend_from_slice(&out);
if full == exif { Cow::Borrowed(exif) } else { Cow::Owned(full) }
}
fn put_u16(order: Order, v: u16) -> [u8; 2] {
match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
}
}
fn put_u32(order: Order, v: u32) -> [u8; 4] {
match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
}
}
fn put_u64(order: Order, v: u64) -> [u8; 8] {
match order {
Order::Little => v.to_le_bytes(),
Order::Big => v.to_be_bytes(),
}
}
/// Removes the IFD0 entries at offsets `removed` (see [`exif_with_resolution`]).
fn remove_entries(b: &mut [u8], ifd: &Ifd0, removed: &[usize]) {
let eb = ifd.entry_bytes;
let next_bytes = if ifd.big { 8 } else { 4 };
let count_bytes = if ifd.big { 8 } else { 2 };
let Some(table_end) = ifd.count.checked_mul(eb).and_then(|n| ifd.first.checked_add(n)) else { return };
let Some(old) = b.get(ifd.first..table_end.saturating_add(next_bytes)).map(<[u8]>::to_vec) else { return };
let mut table = Vec::with_capacity(old.len());
let mut kept = 0usize;
for (i, entry) in old.chunks_exact(eb).take(ifd.count).enumerate() {
if !removed.contains(&(ifd.first + i * eb)) {
table.extend_from_slice(entry);
kept += 1;
}
}
table.extend_from_slice(old.get(ifd.count * eb..).unwrap_or_default());
table.resize(old.len(), 0);
let count: Vec<u8> = if ifd.big { put_u64(ifd.order, kept as u64).to_vec() } else { put_u16(ifd.order, kept as u16).to_vec() };
let Some(count_at) = ifd.first.checked_sub(count_bytes) else { return };
if let Some(slot) = b.get_mut(count_at..ifd.first) {
slot.copy_from_slice(&count);
}
if let Some(slot) = b.get_mut(ifd.first..ifd.first + table.len()) {
slot.copy_from_slice(&table);
}
}
/// The resolution in a Photoshop image resource block (a sequence of `8BIM` resources, as in a
/// JPEG's APP13 "Photoshop 3.0" segment or TIFF tag 34377): its ResolutionInfo (0x03ED), whose
/// Fixed 16.16 hRes / vRes are pixels per inch whatever display unit follows them.
pub fn photoshop_resolution(resources: &[u8]) -> Option<(f32, f32)> {
let be16 = |at: usize| resources.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]));
let be32 = |at: usize| resources.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
let mut i = 0usize;
while i < resources.len() {
let signature = resources.get(i..i.checked_add(4)?)?;
let id = be16(i.checked_add(4)?)?;
// Pascal name: length byte plus text, padded to an even size.
let name_len = usize::from(*resources.get(i.checked_add(6)?)?);
let name = (name_len + 1).next_multiple_of(2);
let size_at = i.checked_add(6)?.checked_add(name)?;
let size = usize::try_from(be32(size_at)?).ok()?;
let data = size_at.checked_add(4)?;
if signature == b"8BIM" && id == RESOLUTION_INFO {
let info = resources.get(data..data.checked_add(size)?)?;
if info.len() < 16 {
return None;
}
let fixed = |at: usize| be32(data + at).map(|v| f64::from(v) / 65536.0);
return ppi_pair(fixed(0)?, fixed(8)?);
}
i = data.checked_add(size)?.checked_add(size % 2)?;
}
None
}
/// The text ranges of every value of XMP property `key` (`key="v"`, `key='v'` or
/// `<key>v</key>`), in order.
fn xmp_values(xmp: &str, key: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut from = 0;
while let Some(k) = xmp.get(from..).and_then(|s| s.find(key)).map(|k| k + from) {
from = k + key.len();
// An attribute or an opening tag of exactly this name (not `</key>`, not `xkey`).
if !xmp.get(..k).and_then(|h| h.chars().next_back()).is_some_and(|c| c == '<' || c.is_ascii_whitespace()) {
continue;
}
let rest = xmp.get(from..).unwrap_or_default();
let after_key = rest.trim_start();
let (value, close) = if let Some(r) = after_key.strip_prefix('=') {
let r = r.trim_start();
match r.chars().next() {
Some(q @ ('"' | '\'')) => (r.get(1..).unwrap_or_default(), q),
_ => continue,
}
} else if let Some(r) = rest.strip_prefix('>') {
(r, '<')
} else {
continue;
};
let start = xmp.len() - value.len();
let Some(len) = value.find(close) else { continue };
out.push((start, start + len));
}
out
}
/// An XMP rational (`300/1`) or plain number.
fn xmp_number(s: &str) -> Option<f64> {
let s = s.trim();
match s.split_once('/') {
Some((n, d)) => {
let (n, d) = (n.trim().parse::<f64>().ok()?, d.trim().parse::<f64>().ok()?);
(d != 0.0).then(|| n / d)
}
None => s.parse().ok(),
}
}
const XMP_X: &str = "tiff:XResolution";
const XMP_Y: &str = "tiff:YResolution";
const XMP_UNIT: &str = "tiff:ResolutionUnit";
/// The resolution (pixels per inch) in an XMP packet's `tiff:XResolution` / `tiff:YResolution`
/// (with `tiff:ResolutionUnit`, 2 when absent, 3 converted from centimetres), or `None`.
pub fn xmp_resolution(xmp: &str) -> Option<(f32, f32)> {
let first = |key: &str| xmp_values(xmp, key).first().and_then(|&(a, b)| xmp.get(a..b));
let unit = match first(XMP_UNIT) {
None => 2.0,
Some(u) => xmp_number(u)?,
};
let scale = if unit == 2.0 {
1.0
} else if unit == 3.0 {
CM_PER_INCH
} else {
return None;
};
ppi_pair(xmp_number(first(XMP_X)?)? * scale, xmp_number(first(XMP_Y)?)? * scale)
}
/// `xmp` with its `tiff:` resolution properties set to `ppi` (and the unit to 2, inch),
/// borrowed when it has none or already says so.
pub fn xmp_with_resolution(xmp: &str, ppi: (f32, f32)) -> Cow<'_, str> {
if ppi_pair(f64::from(ppi.0), f64::from(ppi.1)).is_none() || xmp_resolution(xmp).is_some_and(|r| same(r, ppi)) {
return Cow::Borrowed(xmp);
}
let text = |v: f32| {
let (n, d) = rational(v);
format!("{n}/{d}")
};
let mut edits: Vec<(usize, usize, String)> = Vec::new();
for (key, value) in [(XMP_X, text(ppi.0)), (XMP_Y, text(ppi.1)), (XMP_UNIT, "2".to_owned())] {
edits.extend(xmp_values(xmp, key).into_iter().map(|(a, b)| (a, b, value.clone())));
}
if edits.is_empty() {
return Cow::Borrowed(xmp);
}
edits.sort_by_key(|e| e.0);
let mut out = String::with_capacity(xmp.len() + 16);
let mut at = 0;
for (a, b, v) in edits {
// Overlapping ranges can only come from malformed markup; keep the first edit.
if a < at {
continue;
}
out.push_str(xmp.get(at..a).unwrap_or_default());
out.push_str(&v);
at = b;
}
out.push_str(xmp.get(at..).unwrap_or_default());
if out == xmp { Cow::Borrowed(xmp) } else { Cow::Owned(out) }
}
/// The EXIF to write next to upright pixels at resolution `ppi`: Orientation 1 (see
/// [`upright_exif`]) and, when `ppi` is known, the resolution (see [`exif_with_resolution`]).
pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
let up = upright_exif(exif);
let Some(ppi) = ppi else { return up };
if let Cow::Owned(v) = exif_with_resolution(&up, ppi) {
return Cow::Owned(v);
}
up
}
/// The XMP to write next to upright pixels at resolution `ppi` (see [`upright_xmp`] and
/// [`xmp_with_resolution`]).
pub fn export_xmp(xmp: &str, ppi: Option<(f32, f32)>) -> Cow<'_, str> {
let up = upright_xmp(xmp);
let Some(ppi) = ppi else { return up };
if let Cow::Owned(v) = xmp_with_resolution(&up, ppi) {
return Cow::Owned(v);
}
up
}
/// The resolution a decoded file's metadata blocks record, in precedence order: XMP, then
/// EXIF (Photoshop's ResolutionInfo is format-specific and read by the decoder).
pub(crate) fn from_metadata(exif: Option<&[u8]>, xmp: Option<&str>) -> Option<(f32, f32)> {
xmp.and_then(xmp_resolution).or_else(|| exif.and_then(exif_resolution))
}
+13
View File
@@ -316,6 +316,8 @@ pub fn encode_jpeg_rgb8(
enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v });
}
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 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)?;
@@ -412,6 +414,17 @@ mod tests {
assert!(prog.windows(2).any(|w| w == [0xFF, 0xC2]));
assert_eq!(crate::decode(&prog).unwrap().dimensions(), (64, 48));
}
/// #1691: the XMP resolution of a Save for Web JPEG says what its JFIF density says.
#[test]
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 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)));
}
}
#[cfg(test)]
+438
View File
@@ -0,0 +1,438 @@
//! Resolution recorded in metadata (#1691): a camera JPEG keeps its resolution in EXIF only, and
//! Photoshop opens it at that resolution; Photoshop's own APP13 ResolutionInfo and the XMP
//! `tiff:` properties outrank EXIF, and the JFIF density comes last. Exports write the image's
//! resolution into JFIF, EXIF and XMP alike. Every file here is built by hand.
use std::borrow::Cow;
use photocraft_codecs::resolution::{exif_with_resolution, xmp_with_resolution};
use photocraft_codecs::*;
use proptest::prelude::{any, proptest};
const X: u16 = 282;
const Y: u16 = 283;
const UNIT: u16 = 296;
const SHORT: u16 = 3;
const LONG: u16 = 4;
const RATIONAL: u16 = 5;
const ASCII: u16 = 2;
/// One IFD0 entry: tag, type, count and the value bytes (in the block's byte order).
struct E(u16, u16, u32, Vec<u8>);
struct Exif {
big: bool,
}
impl Exif {
fn u16(&self, v: u16) -> [u8; 2] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn u32(&self, v: u32) -> [u8; 4] {
if self.big { v.to_be_bytes() } else { v.to_le_bytes() }
}
fn rational(&self, n: u32, d: u32) -> Vec<u8> {
[self.u32(n), self.u32(d)].concat()
}
fn short(&self, v: u16) -> Vec<u8> {
self.u16(v).to_vec()
}
fn x(&self, n: u32, d: u32) -> E {
E(X, RATIONAL, 1, self.rational(n, d))
}
fn y(&self, n: u32, d: u32) -> E {
E(Y, RATIONAL, 1, self.rational(n, d))
}
fn unit(&self, u: u16) -> E {
E(UNIT, SHORT, 1, self.short(u))
}
fn make(&self) -> E {
E(0x010F, ASCII, 6, b"NIKON\0".to_vec())
}
fn orientation(&self, o: u16) -> E {
E(0x0112, SHORT, 1, self.short(o))
}
/// A TIFF-structured EXIF block: header, IFD0 at 8, then the out-of-line values.
fn build(&self, entries: &[E]) -> Vec<u8> {
let mut v = if self.big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() };
v.extend_from_slice(&self.u32(8));
v.extend_from_slice(&self.u16(entries.len() as u16));
let mut data_at = 8 + 2 + entries.len() * 12 + 4;
let mut data = Vec::new();
for E(tag, ty, count, value) in entries {
v.extend_from_slice(&self.u16(*tag));
v.extend_from_slice(&self.u16(*ty));
v.extend_from_slice(&self.u32(*count));
if value.len() <= 4 {
let mut inline = value.clone();
inline.resize(4, 0);
v.extend_from_slice(&inline);
} else {
v.extend_from_slice(&self.u32(data_at as u32));
data.extend_from_slice(value);
data_at += value.len();
}
}
v.extend_from_slice(&self.u32(0));
v.extend_from_slice(&data);
v
}
}
const LE: Exif = Exif { big: false };
const BE: Exif = Exif { big: true };
/// A D3200-style block: Make, XResolution 300/1, YResolution 300/1, ResolutionUnit 2.
fn camera_exif(e: &Exif) -> Vec<u8> {
e.build(&[e.make(), e.x(300, 1), e.y(300, 1), e.unit(2)])
}
fn segment(marker: u8, payload: &[u8]) -> Vec<u8> {
let mut s = vec![0xFF, marker];
s.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes());
s.extend_from_slice(payload);
s
}
fn jfif(unit: u8, x: u16, y: u16) -> Vec<u8> {
let mut p = b"JFIF\0\x01\x02".to_vec();
p.push(unit);
p.extend_from_slice(&x.to_be_bytes());
p.extend_from_slice(&y.to_be_bytes());
p.extend_from_slice(&[0, 0]);
segment(0xE0, &p)
}
fn exif_app1(exif: &[u8]) -> Vec<u8> {
segment(0xE1, &[b"Exif\0\0".as_slice(), exif].concat())
}
fn xmp_app1(xmp: &str) -> Vec<u8> {
segment(0xE1, &[b"http://ns.adobe.com/xap/1.0/\0".as_slice(), xmp.as_bytes()].concat())
}
/// An 8BIM resource block holding ResolutionInfo at `ppi` (display unit inch), after an
/// unrelated resource with an odd-length name and an odd-sized body.
fn irb(ppi: f64) -> Vec<u8> {
let mut b = b"8BIM".to_vec();
b.extend_from_slice(&0x0404u16.to_be_bytes());
b.extend_from_slice(&[3, b'a', b'b', b'c']);
b.extend_from_slice(&3u32.to_be_bytes());
b.extend_from_slice(&[1, 2, 3, 0]);
b.extend_from_slice(b"8BIM");
b.extend_from_slice(&0x03EDu16.to_be_bytes());
b.extend_from_slice(&[0, 0]);
b.extend_from_slice(&16u32.to_be_bytes());
let fixed = ((ppi * 65536.0).round() as u32).to_be_bytes();
for _ in 0..2 {
b.extend_from_slice(&fixed);
b.extend_from_slice(&1u16.to_be_bytes());
b.extend_from_slice(&2u16.to_be_bytes());
}
b
}
fn app13(resources: &[u8]) -> Vec<u8> {
segment(0xED, &[b"Photoshop 3.0\0".as_slice(), resources].concat())
}
/// A 16×8 baseline JPEG whose APPn segments are exactly `segments` (the encoder's own JFIF
/// header is taken out).
fn jpeg_with(segments: &[Vec<u8>]) -> Vec<u8> {
let img = Image::from_raw(16, 8, ChannelLayout::Gray, SampleType::U8, vec![128; 128]).unwrap();
let base = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let mut i = 2;
while (0xE0..=0xEF).contains(&base[i + 1]) {
i += 2 + u16::from_be_bytes([base[i + 2], base[i + 3]]) as usize;
}
let mut out = base[..2].to_vec();
for s in segments {
out.extend_from_slice(s);
}
out.extend_from_slice(&base[i..]);
out
}
fn dpi_of(bytes: &[u8]) -> Option<(f32, f32)> {
decode(bytes).unwrap().meta.dpi
}
fn near(got: Option<(f32, f32)>, want: f32) {
let (x, y) = got.unwrap_or_else(|| panic!("no resolution, wanted {want}"));
assert!((x - want).abs() < 0.01 && (y - want).abs() < 0.01, "got {x}×{y}, wanted {want}");
}
/// The APPn payloads of a JPEG: (marker, payload).
fn app_segments(b: &[u8]) -> Vec<(u8, Vec<u8>)> {
let mut out = Vec::new();
let mut i = 2;
while i + 4 <= b.len() && b[i] == 0xFF && b[i + 1] != 0xDA {
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
if (0xE0..=0xEF).contains(&b[i + 1]) {
out.push((b[i + 1], b[i + 4..i + 2 + len].to_vec()));
}
i += 2 + len;
}
out
}
/// (unit, x, y) of a JPEG's JFIF header.
fn jfif_density(b: &[u8]) -> (u8, u16, u16) {
let (_, p) = app_segments(b).into_iter().find(|(m, p)| *m == 0xE0 && p.starts_with(b"JFIF\0")).unwrap();
(p[7], u16::from_be_bytes([p[8], p[9]]), u16::from_be_bytes([p[10], p[11]]))
}
fn exif_of(b: &[u8]) -> Vec<u8> {
let (_, p) = app_segments(b).into_iter().find(|(m, p)| *m == 0xE1 && p.starts_with(b"Exif\0\0")).unwrap();
p[6..].to_vec()
}
#[test]
fn exif_only_camera_jpeg_opens_at_its_exif_resolution() {
for e in [&LE, &BE] {
near(dpi_of(&jpeg_with(&[exif_app1(&camera_exif(e))])), 300.0);
// ResolutionUnit absent: inch, the EXIF default.
near(dpi_of(&jpeg_with(&[exif_app1(&e.build(&[e.x(300, 1), e.y(300, 1)]))])), 300.0);
// Non-unit denominators.
near(dpi_of(&jpeg_with(&[exif_app1(&e.build(&[e.x(3_000_000, 10_000), e.y(1200, 4), e.unit(2)]))])), 300.0);
}
}
#[test]
fn exif_resolution_in_centimetres_is_converted() {
for e in [&LE, &BE] {
let exif = e.build(&[e.x(11811, 100), e.y(11811, 100), e.unit(3)]);
near(exif_resolution(&exif), 299.9994);
near(dpi_of(&jpeg_with(&[exif_app1(&exif)])), 299.9994);
}
}
#[test]
fn jfif_aspect_ratio_only_with_exif_uses_exif() {
near(dpi_of(&jpeg_with(&[jfif(0, 1, 1), exif_app1(&camera_exif(&LE))])), 300.0);
}
#[test]
fn exif_outranks_a_default_jfif_density() {
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif_app1(&camera_exif(&LE))])), 300.0);
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif_app1(&camera_exif(&BE))])), 300.0);
// JFIF alone still counts, in either unit.
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72)])), 72.0);
near(dpi_of(&jpeg_with(&[jfif(2, 100, 100)])), 254.0);
assert_eq!(dpi_of(&jpeg_with(&[jfif(0, 1, 1)])), None);
}
#[test]
fn photoshop_resolution_info_wins_then_xmp_then_exif() {
let xmp = r#"<x:xmpmeta><rdf:Description tiff:XResolution="200/1" tiff:YResolution="200/1" tiff:ResolutionUnit="2"/></x:xmpmeta>"#;
let exif = exif_app1(&camera_exif(&LE));
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif.clone(), xmp_app1(xmp), app13(&irb(240.0))])), 240.0);
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif.clone(), xmp_app1(xmp)])), 200.0);
// A fractional Photoshop resolution survives the 16.16 fixed point.
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif, app13(&irb(299.5))])), 299.5);
near(photoshop_resolution(&irb(150.0)), 150.0);
// Element form, centimetres.
let elem = "<tiff:XResolution>100/1</tiff:XResolution><tiff:YResolution>100</tiff:YResolution><tiff:ResolutionUnit>3</tiff:ResolutionUnit>";
near(xmp_resolution(elem), 254.0);
}
#[test]
fn unusable_exif_falls_back_to_jfif() {
let e = &LE;
let bad = [
// No absolute unit.
e.build(&[e.x(300, 1), e.y(300, 1), e.unit(1)]),
// Unknown unit, malformed unit entry.
e.build(&[e.x(300, 1), e.y(300, 1), e.unit(9)]),
e.build(&[e.x(300, 1), e.y(300, 1), E(UNIT, LONG, 1, e.u32(2).to_vec())]),
// Zero denominator, zero value, wrong type, wrong count.
e.build(&[e.x(300, 0), e.y(300, 1)]),
e.build(&[e.x(0, 1), e.y(300, 1)]),
e.build(&[E(X, SHORT, 1, e.short(300)), e.y(300, 1)]),
e.build(&[E(X, RATIONAL, 2, [e.rational(300, 1), e.rational(300, 1)].concat()), e.y(300, 1)]),
// Only one axis.
e.build(&[e.x(300, 1)]),
];
for exif in &bad {
assert_eq!(exif_resolution(exif), None);
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif_app1(exif)])), 72.0);
}
// A value offset pointing past the end.
let mut far = camera_exif(e);
let at = 8 + 2 + 12 + 8; // XResolution entry's value field
far[at..at + 4].copy_from_slice(&e.u32(60_000));
assert_eq!(exif_resolution(&far), None);
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif_app1(&far)])), 72.0);
}
#[test]
fn truncated_metadata_never_panics_and_falls_back() {
let exif = camera_exif(&BE);
for n in 0..exif.len() {
let _ = exif_resolution(&exif[..n]);
let _ = exif_with_resolution(&exif[..n], (240.0, 240.0));
let dpi = dpi_of(&jpeg_with(&[jfif(1, 72, 72), exif_app1(&exif[..n])]));
assert!(dpi == Some((72.0, 72.0)) || dpi == Some((300.0, 300.0)), "{n}: {dpi:?}");
}
let r = irb(300.0);
for n in 0..r.len() {
assert_eq!(photoshop_resolution(&r[..n]), None, "{n}");
near(dpi_of(&jpeg_with(&[jfif(1, 72, 72), app13(&r[..n])])), 72.0);
}
for x in ["tiff:XResolution=", "tiff:XResolution=\"", "tiff:XResolution=\"\" tiff:YResolution=\"5/0\"", "<tiff:XResolution>", "</tiff:XResolution>9<"] {
assert_eq!(xmp_resolution(x), None, "{x}");
let _ = xmp_with_resolution(x, (1.0, 1.0));
}
}
proptest! {
#[test]
fn hostile_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..256), ppi in 0.0f32..2e6) {
let _ = exif_resolution(&bytes);
let _ = exif_with_resolution(&bytes, (ppi, ppi));
let _ = photoshop_resolution(&bytes);
let s = String::from_utf8_lossy(&bytes);
let _ = xmp_resolution(&s);
let _ = xmp_with_resolution(&s, (ppi, ppi));
}
#[test]
fn mutated_exif_never_panics(i in 0usize..80, v in any::<u8>(), ppi in 1.0f32..10_000.0) {
let mut exif = camera_exif(&LE);
if let Some(b) = exif.get_mut(i) { *b = v; }
let _ = exif_resolution(&exif);
let fixed = exif_with_resolution(&exif, (ppi, ppi));
let _ = exif_resolution(&fixed);
let _ = exif_orientation(&fixed);
}
}
#[test]
fn rewriting_keeps_every_other_byte() {
for e in [&LE, &BE] {
let src = e.build(&[e.make(), e.orientation(6), e.x(300, 1), e.y(300, 1), e.unit(3)]);
let out = exif_with_resolution(&src, (240.0, 240.0)).into_owned();
assert_eq!(out.len(), src.len());
near(exif_resolution(&out), 240.0);
let want = e.build(&[e.make(), e.orientation(6), e.x(240, 1), e.y(240, 1), e.unit(2)]);
assert_eq!(out, want);
// Fractions keep three decimals.
near(exif_resolution(&exif_with_resolution(&src, (299.5, 72.25))).map(|(x, _)| (x, x)), 299.5);
// Nothing to change: borrowed.
assert!(matches!(exif_with_resolution(&camera_exif(e), (300.0, 300.0)), Cow::Borrowed(_)));
let no_res = e.build(&[e.make()]);
assert!(matches!(exif_with_resolution(&no_res, (240.0, 240.0)), Cow::Borrowed(_)));
// With the JPEG prefix.
let prefixed = [b"Exif\0\0".as_slice(), &src].concat();
assert_eq!(exif_with_resolution(&prefixed, (240.0, 240.0)).as_ref(), [b"Exif\0\0".as_slice(), &want].concat());
}
}
#[test]
fn entries_that_cannot_be_rewritten_are_removed() {
for e in [&LE, &BE] {
// XResolution as a SHORT and a LONG unit: both go, the rest stays readable.
let src = e.build(&[e.make(), E(X, SHORT, 1, e.short(300)), e.y(300, 1), E(UNIT, LONG, 1, e.u32(2).to_vec()), e.orientation(6)]);
let out = exif_with_resolution(&src, (240.0, 240.0)).into_owned();
assert_eq!(out.len(), src.len());
let count = if e.big { u16::from_be_bytes([out[8], out[9]]) } else { u16::from_le_bytes([out[8], out[9]]) };
assert_eq!(count, 3);
assert_eq!(exif_orientation(&out), 6);
// Only YResolution is left (240): no X, so no resolution at all, and no stale 300.
assert_eq!(exif_resolution(&out), None);
let tags: Vec<u16> = (0..3)
.map(|k| {
let at = 10 + k * 12;
if e.big { u16::from_be_bytes([out[at], out[at + 1]]) } else { u16::from_le_bytes([out[at], out[at + 1]]) }
})
.collect();
assert_eq!(tags, vec![0x010F, Y, 0x0112]);
// The next-IFD pointer followed the table, the freed entries are zero.
assert_eq!(&out[10 + 36..10 + 40], &[0, 0, 0, 0]);
assert!(out[10 + 40..10 + 64].iter().all(|&b| b == 0));
// The Make string (out of line) did not move.
assert!(out.windows(6).any(|w| w == b"NIKON\0"));
}
}
#[test]
fn xmp_resolution_is_rewritten() {
let attr = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution='300/1' tiff:ResolutionUnit="3" tiff:Make="X"/>"#;
assert_eq!(
xmp_with_resolution(attr, (240.0, 240.0)),
r#"<rdf:Description tiff:XResolution="240/1" tiff:YResolution='240/1' tiff:ResolutionUnit="2" tiff:Make="X"/>"#
);
let elem = "<tiff:XResolution>300/1</tiff:XResolution><tiff:YResolution>300/1</tiff:YResolution>";
assert_eq!(xmp_with_resolution(elem, (72.5, 72.5)), "<tiff:XResolution>72500/1000</tiff:XResolution><tiff:YResolution>72500/1000</tiff:YResolution>");
for same in [r#"tiff:XResolution="240" tiff:YResolution="240/1""#, "<x/>"] {
assert!(matches!(xmp_with_resolution(same, (240.0, 240.0)), Cow::Borrowed(_)), "{same}");
}
}
#[test]
fn jpeg_export_writes_one_resolution_everywhere() {
for e in [&LE, &BE] {
let xmp = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution="300/1"/>"#;
let src = jpeg_with(&[jfif(1, 72, 72), exif_app1(&camera_exif(e)), xmp_app1(xmp)]);
let mut img = decode(&src).unwrap();
near(img.meta.dpi, 300.0);
img.meta.dpi = Some((240.0, 240.0));
let out = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
assert_eq!(jfif_density(&out), (1, 240, 240));
near(exif_resolution(&exif_of(&out)), 240.0);
let (_, x) = app_segments(&out).into_iter().find(|(m, p)| *m == 0xE1 && p.starts_with(b"http://ns.adobe.com/xap/1.0/\0")).unwrap();
near(xmp_resolution(std::str::from_utf8(&x).unwrap()), 240.0);
near(dpi_of(&out), 240.0);
// The rest of the EXIF came along.
assert!(exif_of(&out).windows(6).any(|w| w == b"NIKON\0"));
}
}
#[test]
fn other_formats_take_the_exif_resolution_when_they_have_none() {
let mut img = Image::from_raw(4, 4, ChannelLayout::Rgb, SampleType::U8, vec![90; 48]).unwrap();
img.meta.exif = Some(camera_exif(&LE));
for fmt in [Format::WebP, Format::Png] {
// Written at 240 with the 300 ppi EXIF: the EXIF follows the image.
img.meta.dpi = Some((240.0, 240.0));
let out = encode(&img, fmt, &EncodeOptions::default()).unwrap();
let back = decode(&out).unwrap();
near(back.meta.dpi, 240.0);
near(back.meta.exif.as_deref().and_then(exif_resolution), 240.0);
// Written without a resolution: the EXIF one is read back.
img.meta.dpi = None;
let out = encode(&img, fmt, &EncodeOptions::default()).unwrap();
near(decode(&out).unwrap().meta.dpi, 300.0);
}
}
fn crc32(data: &[u8]) -> u32 {
let mut c = !0u32;
for &b in data {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
#[test]
fn png_phys_outranks_its_exif() {
// eXIf says 300 ppi, then a pHYs chunk of 150 ppi (5906 px/m) goes in after IHDR.
let mut img = Image::from_raw(4, 4, ChannelLayout::Rgb, SampleType::U8, vec![90; 48]).unwrap();
img.meta.exif = Some(camera_exif(&LE));
let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
near(decode(&png).unwrap().meta.dpi, 300.0);
let mut body = b"pHYs".to_vec();
body.extend_from_slice(&5906u32.to_be_bytes());
body.extend_from_slice(&5906u32.to_be_bytes());
body.push(1);
let mut chunk = 9u32.to_be_bytes().to_vec();
chunk.extend_from_slice(&body);
chunk.extend_from_slice(&crc32(&body).to_be_bytes());
let ihdr_end = 8 + 8 + 13 + 4;
let with_phys = [&png[..ihdr_end], &chunk, &png[ihdr_end..]].concat();
near(decode(&with_phys).unwrap().meta.dpi, 150.0114);
}
+3 -7
View File
@@ -181,14 +181,10 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
}
/// Runs `f` with this thread's document blend flags set ([`psblend::LAB_MIX`], [`psblend::HDR`]),
/// clearing them afterwards.
/// then restores the values they had (also on unwind): a nested rayon job can run another tile
/// on this thread mid-composite, and clearing the flags there broke the outer tile (#1112).
fn in_blend_space<R>(lab: bool, hdr: bool, f: impl FnOnce() -> R) -> R {
psblend::LAB_MIX.with(|l| l.set(lab));
psblend::HDR.with(|h| h.set(hdr));
let r = f();
psblend::LAB_MIX.with(|l| l.set(false));
psblend::HDR.with(|h| h.set(false));
r
psblend::with_lab_mix(lab, || psblend::with_hdr(hdr, f))
}
/// The document's own CMYK profile for reading its CMYK pixels (`None`: not a CMYK document,
+27
View File
@@ -81,6 +81,33 @@ thread_local! {
pub static HDR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Runs `f` with [`LAB_MIX`] set to `lab`, then restores the value it had (also on unwind). A
/// nested rayon job can run another tile on this thread mid-composite; clearing the flag there
/// made the rest of the outer tile mix in sRGB (#1112).
pub fn with_lab_mix<R>(lab: bool, f: impl FnOnce() -> R) -> R {
struct Restore(bool);
impl Drop for Restore {
fn drop(&mut self) {
LAB_MIX.with(|l| l.set(self.0));
}
}
let _restore = Restore(LAB_MIX.with(|l| l.replace(lab)));
f()
}
/// Runs `f` with [`HDR`] set to `hdr`, then restores the value it had (also on unwind), like
/// [`with_lab_mix`].
pub fn with_hdr<R>(hdr: bool, f: impl FnOnce() -> R) -> R {
struct Restore(bool);
impl Drop for Restore {
fn drop(&mut self) {
HDR.with(|h| h.set(self.0));
}
}
let _restore = Restore(HDR.with(|h| h.replace(hdr)));
f()
}
/// `B(Cb, Cs)` with Photoshop's variants.
pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
match mode {
+20
View File
@@ -1654,6 +1654,26 @@ fn photo_filter_matches_photoshop() {
}
}
#[test]
fn rendering_restores_the_lab_mix_flag_it_found() {
// #1112: a render set LAB_MIX for its document, then cleared it. A nested rayon job (a
// gradient fill, say) can let a thread render another tile in the middle of an outer Lab
// tile, and clearing the flag made the rest of that outer tile mix in sRGB.
for mode in [ColorMode::Lab, ColorMode::Rgb] {
let doc = Document::with_background("t", Size::new(8, 8), mode, SampleType::U8, Color::WHITE);
// Single-tile and multi-tile paths (the latter also prepares effect maps under the flag).
for tile in [RENDER_TILE, 4] {
for outer in [true, false] {
psblend::LAB_MIX.with(|l| l.set(outer));
let _ = render_tiled(&doc, doc.bounds(), tile);
let after = psblend::LAB_MIX.with(|l| l.get());
psblend::LAB_MIX.with(|l| l.set(false));
assert_eq!(after, outer, "{mode:?} with tile {tile} inside a tile that had LAB_MIX = {outer}");
}
}
}
}
/// Vibrance of an 8-bit sRGB colour (0–255 in and out).
fn vibrance_255(c: [f32; 3], vibrance: f32, saturation: f32) -> [f32; 3] {
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [c[0] / 255.0, c[1] / 255.0, c[2] / 255.0, 1.0]);
+1
View File
@@ -1126,6 +1126,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::prefs::specs());
v.extend(crate::edit_menu_cmds::specs());
v.extend(crate::fill_key_cmds::specs());
v.extend(crate::sample_cmds::specs());
v.extend(crate::brush_key_cmds::specs());
v.extend(crate::stamp_cmds::specs());
v.extend(crate::align_cmds::specs());
+8 -1
View File
@@ -1,6 +1,6 @@
//! JSON views of engine state for automation, tests and debugging.
use photocraft_doc::{Layer, LayerContent};
use photocraft_doc::{Layer, LayerContent, SmartSource};
use serde_json::{Value, json};
use crate::{DocState, Session};
@@ -103,6 +103,13 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value {
.collect(),
);
v["smartFiltersEnabled"] = json!(sm.filters_enabled);
// Where the contents come from. A PSD placed layer (embedded or linked in Photoshop) is
// `linked` to the `Idnt` uuid of its file, which its duplicates share; New Smart Object
// via Copy embeds its own copy of the file instead.
v["smartSource"] = match &sm.source {
SmartSource::Embedded { file_name, .. } => json!({"kind": "embedded", "fileName": file_name}),
SmartSource::Linked { path } => json!({"kind": "linked", "path": path}),
};
}
LayerContent::Text(t) => {
v["text"] = json!({"text": t.text, "font": t.font_family, "sizePt": t.size_pt});
+71 -1
View File
@@ -5,7 +5,7 @@
use photocraft_algo::selection::Region;
use photocraft_color::{BlendMode, ColorMode};
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Effects, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
use serde_json::{Value, json};
@@ -605,6 +605,68 @@ fn scale_effects(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Dragging a layer's fx onto another layer in the Layers panel: all its effects (or just the one
/// at `effect`) move there, or with `copy` are copied. Moving all effects replaces the target's
/// style, as pasting a layer style does; a single effect replaces the target's effect of the same
/// kind, else is added.
fn transfer_effects(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.layerStyle.transferEffects";
let id_of = |key: &str| p.get(key).and_then(Value::as_u64).map(LayerId).ok_or_else(|| bad(CMD, format!("`{key}` must be a layer id")));
let from = id_of("from")?;
let to = id_of("to")?;
if from == to {
return Err(bad(CMD, "the effects are already on that layer"));
}
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let index = match p.get("effect") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "`effect` must be an effect index"))?),
};
let d = s.active().ok_or(EngineError::NoDocument)?;
let src = d.doc.layer(from).ok_or(EngineError::NoLayer(from))?;
d.doc.layer(to).ok_or(EngineError::NoLayer(to))?;
let payload = match index {
Some(i) => {
let e = src.effects.items.get(i).cloned().ok_or_else(|| bad(CMD, format!("the layer has no effect {i}")))?;
Effects { enabled: true, items: vec![e], psd_raw: None, reference: None }
}
None => Effects { psd_raw: None, ..src.effects.clone() },
};
if payload.items.is_empty() {
return Err(bad(CMD, "the layer has no layer effects"));
}
let label = if copy { "Copy Layer Effects" } else { "Move Layer Effects" };
s.edit(label, |doc, _| {
let dst = doc.layer_mut(to).ok_or(EngineError::NoLayer(to))?;
dst.effects.psd_raw = None;
if index.is_some() {
dst.effects.enabled = true;
for e in payload.items {
let same = dst.effects.items.iter().position(|x| std::mem::discriminant(x) == std::mem::discriminant(&e));
match same.and_then(|i| dst.effects.items.get_mut(i)) {
Some(slot) => *slot = e,
None => dst.effects.items.push(e),
}
}
} else {
dst.effects = payload;
}
if !copy {
let src = doc.layer_mut(from).ok_or(EngineError::NoLayer(from))?;
match index {
Some(i) if i < src.effects.items.len() => {
src.effects.items.remove(i);
src.effects.psd_raw = None;
}
Some(_) => {}
None => src.effects = Effects::default(),
}
}
Ok(())
})?;
Ok(json!({"from": from.0, "to": to.0}))
}
/// Effects drawn beneath the layer (they become separate layers below it, unclipped).
fn is_below(e: &Effect) -> bool {
match e {
@@ -861,6 +923,14 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("layer.layerStyle.createLayer", "Create Layer", ["Layer", "Layer Style"], r##"{"layer":id?}"##, has_effects, create_layer),
spec!("layer.layerStyle.scaleEffects", "Scale Effects…", ["Layer", "Layer Style"], r##"{"scale":1..1000=100}"##, has_effects, scale_effects),
spec!(
"layer.layerStyle.transferEffects",
"Move Layer Effects",
[],
r##"{"from":id,"to":id,"effect":index? (default: all effects),"copy":bool=false}"##,
has_doc,
transfer_effects
),
CommandSpec {
id: "layer.layerContentOptions",
label: "Layer Content Options…",
@@ -526,3 +526,56 @@ fn load_files_into_stack_as_smart_object_then_median() {
assert!((px[0] - 0.2).abs() < 2.0 / 255.0, "{px:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn transfer_effects_moves_or_copies_between_layers() {
let mut s = session(8, "rgb");
s.execute("layer.new.layer", json!({})).unwrap();
let a = active(&s).id;
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let b = active(&s).id;
let layer = |s: &Session, id| s.active().unwrap().doc.layer(id).unwrap().clone();
let labels = |l: &Layer| l.effects.items.iter().map(Effect::label).collect::<Vec<_>>();
let both = labels(&layer(&s, a));
assert_eq!(both.len(), 2);
// Alt-drag of the whole fx row copies: both layers have the style.
s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": b.0, "copy": true})).unwrap();
assert_eq!(labels(&layer(&s, a)), both);
assert_eq!(labels(&layer(&s, b)), both);
// Dragging one effect row moves just that effect.
s.execute("layer.new.layer", json!({})).unwrap();
let c = active(&s).id;
s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": c.0, "effect": 1})).unwrap();
assert_eq!(labels(&layer(&s, a)), both[..1]);
assert_eq!(labels(&layer(&s, c)), both[1..]);
// Dragging the whole fx row moves everything and leaves the source without a style.
s.execute("layer.layerStyle.transferEffects", json!({"from": b.0, "to": a.0})).unwrap();
assert!(layer(&s, b).effects.items.is_empty());
assert_eq!(labels(&layer(&s, a)), both);
// One history step per drop.
let steps = s.active().unwrap().history.past_len();
s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": b.0})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(labels(&layer(&s, a)), both);
assert!(layer(&s, b).effects.items.is_empty());
// Bad input is an error and changes nothing.
for bad in [
json!({"from": a.0, "to": a.0}),
json!({"from": a.0}),
json!({"from": a.0, "to": 9999}),
json!({"from": a.0, "to": b.0, "effect": 7}),
json!({"from": a.0, "to": b.0, "effect": -1}),
json!({"from": b.0, "to": a.0}),
] {
assert!(s.execute("layer.layerStyle.transferEffects", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
+1
View File
@@ -77,6 +77,7 @@ pub mod proof_sim;
pub mod redeye_cmds;
pub mod render_cmds;
pub mod retouch_cmds;
pub mod sample_cmds;
pub mod select_extra_cmds;
pub mod selection_cmds;
pub mod slice_cmds;
+462
View File
@@ -0,0 +1,462 @@
//! Eyedropper sampling (#1649): Photoshop's Sample Size (Point Sample, or the average of a
//! 3×3 … 101×101 square centred on the clicked pixel) and Sample (which layers the colour comes
//! from). The Eyedropper tool, the painting tools' ⌥-click eyedropper and the Info panel read
//! colours through [`sample_color`], so they share one setting, as in Photoshop.
//!
//! Averages are taken in the document's own colour model and bit depth: the composite under the
//! square is converted to the document's channels (Gray, Lab; CMYK documents are composited in
//! RGB, so their composite RGB is averaged, see [`averaging_model`]), averaged weighted by alpha,
//! rounded to the document's depth (8 and 16 bit; 32 bit keeps the float), then returned as the
//! straight-alpha RGBA the compositor and the tool colours use.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_geom::Rect;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// The Sample Size choices: 1 = Point Sample, otherwise the side of the averaged square.
pub const SAMPLE_SIZES: [u32; 7] = [1, 3, 5, 11, 31, 51, 101];
/// Which layers the Eyedropper samples (its options bar's Sample menu).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SampleLayers {
/// The active layer alone.
Current,
/// The active layer and everything below it.
CurrentAndBelow,
/// The whole visible composite (Photoshop's default).
#[default]
All,
/// [`Self::CurrentAndBelow`] with adjustment layers left out.
CurrentAndBelowNoAdjustments,
/// [`Self::All`] with adjustment layers left out.
AllNoAdjustments,
}
impl SampleLayers {
/// Every choice with its param id, in the options bar's order.
pub const ALL: [(SampleLayers, &'static str); 5] = [
(SampleLayers::Current, "current"),
(SampleLayers::CurrentAndBelow, "currentAndBelow"),
(SampleLayers::All, "all"),
(SampleLayers::AllNoAdjustments, "allNoAdjustments"),
(SampleLayers::CurrentAndBelowNoAdjustments, "currentAndBelowNoAdjustments"),
];
/// The choice named `id` (the `sampleLayer` param), `None` for an unknown one.
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.iter().find(|(_, n)| *n == id).map(|(v, _)| *v)
}
fn no_adjustments(self) -> bool {
matches!(self, SampleLayers::AllNoAdjustments | SampleLayers::CurrentAndBelowNoAdjustments)
}
}
/// The pixels a sample of `size` at document point (`x`, `y`) averages: the `size`×`size` square
/// centred on the pixel under the point, clipped to `canvas`. `None` when the point is off the
/// canvas (or not finite), or `size` is not one of [`SAMPLE_SIZES`].
pub fn sample_rect(x: f64, y: f64, size: u32, canvas: Rect) -> Option<Rect> {
if !(x.is_finite() && y.is_finite()) || !SAMPLE_SIZES.contains(&size) {
return None;
}
let (fx, fy) = (x.floor(), y.floor());
if fx < f64::from(i32::MIN) || fx > f64::from(i32::MAX) || fy < f64::from(i32::MIN) || fy > f64::from(i32::MAX) {
return None;
}
let (px, py) = (fx as i32, fy as i32);
if !canvas.contains(px, py) {
return None;
}
let r = (size / 2) as i32;
let square = Rect::new(px.saturating_sub(r), py.saturating_sub(r), px.saturating_add(r).saturating_add(1), py.saturating_add(r).saturating_add(1));
Some(square.intersect(&canvas))
}
/// Round `v` to what a channel of `depth` can store (32 bit keeps the float).
fn quantize(v: f32, depth: SampleType) -> f32 {
let mut b = [0u8; 4];
photocraft_color::write_sample(&mut b, depth, 0, v);
photocraft_color::read_sample(&b, depth, 0)
}
/// The alpha-weighted average of straight-alpha RGBA pixels `px`, taken in the channels of
/// colour model `mode` at bit depth `depth`: each pixel is converted to `mode`, the colour
/// channels are averaged weighted by alpha (so transparent pixels don't darken the result), the
/// mean is rounded to `depth` and converted back to RGBA. Alpha is the mean coverage. Fully
/// transparent (or empty) input averages to transparent black.
///
/// CMYK conversions use the CMYK space active on this thread
/// (`photocraft_color::convert::with_cmyk_space`).
pub fn average(px: &[[f32; 4]], mode: ColorMode, depth: SampleType) -> [f32; 4] {
let fmt = PixelFormat::new(mode, depth, false);
let n = fmt.channels().min(8);
let mut sum = [0.0f64; 8];
let mut alpha = 0.0f64;
let mut native = [0.0f32; 8];
for p in px {
let a = if p[3].is_finite() { p[3].clamp(0.0, 1.0) } else { 0.0 };
if a <= 0.0 || p[..3].iter().any(|v| !v.is_finite()) {
continue;
}
let k = photocraft_raster::from_rgba_into(&fmt, *p, &mut native).min(n);
for (s, v) in sum.iter_mut().zip(&native[..k]) {
*s += f64::from(*v) * f64::from(a);
}
alpha += f64::from(a);
}
if px.is_empty() || alpha <= 0.0 {
return [0.0; 4];
}
let mean: Vec<f32> = sum[..n].iter().map(|s| quantize((s / alpha) as f32, depth)).collect();
let [r, g, b, _] = photocraft_raster::to_rgba(&fmt, &mean);
[r, g, b, quantize((alpha / px.len() as f64) as f32, depth)]
}
/// The document that renders what `which` samples: the whole document, or a copy with the layers
/// it leaves out hidden. `active` is the active layer (`None`: Current Layer samples nothing).
fn sampled_document(doc: &Document, active: Option<LayerId>, which: SampleLayers) -> Option<std::borrow::Cow<'_, Document>> {
use std::borrow::Cow;
match which {
SampleLayers::All => return Some(Cow::Borrowed(doc)),
SampleLayers::Current => {
// The active layer on its own, as if it were the only layer: its pixels, not how its
// group, blend mode or opacity show it in the composite.
let mut layer = doc.layer(active?)?.clone();
layer.visible = true;
layer.clipped = false;
layer.opacity = 1.0;
layer.fill_opacity = 1.0;
layer.blend = photocraft_color::BlendMode::Normal;
let mut solo = doc.clone();
solo.layers = vec![layer];
return Some(Cow::Owned(solo));
}
_ => {}
}
let mut copy = doc.clone();
let walk: Vec<(Vec<usize>, LayerId, bool)> = doc.walk().into_iter().map(|(p, _, l)| (p, l.id, matches!(l.content, LayerContent::Adjustment(_)))).collect();
if matches!(which, SampleLayers::CurrentAndBelow | SampleLayers::CurrentAndBelowNoAdjustments) {
// Everything composited after (above) the active layer in the bottom-to-top walk, except
// its own contents: the walk lists a group before its children.
let pos = walk.iter().position(|(_, id, _)| Some(*id) == active)?;
let own = walk.get(pos).map(|(p, _, _)| p.clone()).unwrap_or_default();
for (path, _, _) in walk.get(pos + 1..).unwrap_or_default().iter().filter(|(p, _, _)| !p.starts_with(&own)) {
if let Some(l) = copy.layer_at_mut(path) {
l.visible = false;
}
}
}
if which.no_adjustments() {
for (path, _, _) in walk.iter().filter(|(_, _, adj)| *adj) {
if let Some(l) = copy.layer_at_mut(path) {
l.visible = false;
}
}
}
Some(Cow::Owned(copy))
}
/// The colour the Eyedropper picks at document point (`x`, `y`) with Sample Size `size` (see
/// [`SAMPLE_SIZES`]) from the layers `which` names, as straight-alpha RGBA. A Point Sample is the
/// composite pixel itself; larger sizes are the [`average`] of the square around it, clipped to
/// the canvas. Transparent black off the canvas, over transparency, or when `which` needs an
/// active layer and there is none.
pub fn sample_color(doc: &Document, active: Option<LayerId>, x: f64, y: f64, size: u32, which: SampleLayers) -> [f32; 4] {
let Some(rect) = sample_rect(x, y, size, doc.bounds()) else { return [0.0; 4] };
let Some(src) = sampled_document(doc, active, which) else { return [0.0; 4] };
let buf = photocraft_compose::render(&src, rect);
if size == 1 {
return buf.px.first().copied().unwrap_or_default();
}
average(&buf.px, averaging_model(doc), doc.depth)
}
/// The colour model the averages are taken in: the document's, except CMYK. CMYK documents are
/// composited in RGB (see `photocraft_compose`), and the profile's RGB → CMYK → RGB round trip
/// is not exact (deep shadows move by several levels), so averaging in CMYK would make a 3×3
/// sample of a flat area differ from its Point Sample. Their composite RGB is averaged instead.
fn averaging_model(doc: &Document) -> ColorMode {
match doc.pixel_format().mode {
ColorMode::Cmyk => ColorMode::Rgb,
m => m,
}
}
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
/// The `size` param: 1 (Point Sample) or an averaged square's side; "point" is accepted too.
pub fn size_param(cmd: &str, p: &Value) -> Result<u32> {
match p.get("size") {
None | Some(Value::Null) => Ok(1),
Some(Value::String(s)) if s == "point" => Ok(1),
Some(v) => v
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| SAMPLE_SIZES.contains(n))
.ok_or_else(|| bad(cmd, format!("`size` = {v} must be \"point\" or one of {SAMPLE_SIZES:?}"))),
}
}
/// The `sampleLayer` param (default `all`).
pub fn layers_param(cmd: &str, p: &Value) -> Result<SampleLayers> {
match p.get("sampleLayer") {
None | Some(Value::Null) => Ok(SampleLayers::All),
Some(Value::String(s)) => SampleLayers::parse(s).ok_or_else(|| {
let ids: Vec<&str> = SampleLayers::ALL.iter().map(|(_, n)| *n).collect();
bad(cmd, format!("unknown sampleLayer `{s}` ({})", ids.join("|")))
}),
Some(v) => Err(bad(cmd, format!("`sampleLayer` = {v} must be a string"))),
}
}
fn coord(cmd: &str, p: &Value, key: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number (document pixels)")))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "document.sampleColor",
label: "Sample Color",
menu: &[],
shortcut: None,
params: r##"{"x":f64,"y":f64,"size":"point"|1|3|5|11|31|51|101=1,"sampleLayer":"current"|"currentAndBelow"|"all"|"allNoAdjustments"|"currentAndBelowNoAdjustments"="all"} → [r,g,b,a] (the Eyedropper's Sample Size and Sample: the composite pixel, or the average of the size×size square centred on it, clipped to the canvas; transparent off the canvas)"##,
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
run: |s: &mut Session, p: &Value| {
const CMD: &str = "document.sampleColor";
let (x, y) = (coord(CMD, p, "x")?, coord(CMD, p, "y")?);
let size = size_param(CMD, p)?;
let which = layers_param(CMD, p)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
Ok(json!(sample_color(&d.doc, d.active_layer, x, y, size, which)))
},
journal: false,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::{Adjustment, Layer, Size};
/// A `w`×`h` document of `mode`/`depth` whose background is `px(x, y)` (RGBA, converted to
/// the document's channels).
fn doc(w: u32, h: u32, mode: ColorMode, depth: SampleType, px: impl Fn(i32, i32) -> [f32; 4]) -> Document {
let mut d = Document::new("t", Size::new(w, h), mode, depth);
let mut bg = Layer::raster("Background", d.pixel_format());
let s = bg.surface_mut().unwrap();
let fmt = s.format();
for y in 0..h as i32 {
for x in 0..w as i32 {
s.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, px(x, y)));
}
}
d.layers.push(bg);
d
}
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn sample_rect_centres_the_square_and_clips_to_the_canvas() {
let canvas = Rect::new(0, 0, 20, 10);
assert_eq!(sample_rect(5.5, 5.5, 1, canvas), Some(Rect::new(5, 5, 6, 6)));
assert_eq!(sample_rect(5.9, 5.1, 3, canvas), Some(Rect::new(4, 4, 7, 7)));
assert_eq!(sample_rect(10.0, 5.0, 5, canvas), Some(Rect::new(8, 3, 13, 8)));
// Corners and edges: the square is clipped.
assert_eq!(sample_rect(0.0, 0.0, 5, canvas), Some(Rect::new(0, 0, 3, 3)));
assert_eq!(sample_rect(19.5, 9.5, 11, canvas), Some(Rect::new(14, 4, 20, 10)));
assert_eq!(sample_rect(10.0, 5.0, 101, canvas), Some(canvas));
// Off the canvas, non-finite, or not a Photoshop size: nothing.
assert_eq!(sample_rect(-0.5, 3.0, 3, canvas), None);
assert_eq!(sample_rect(20.0, 3.0, 3, canvas), None);
assert_eq!(sample_rect(f64::NAN, 3.0, 3, canvas), None);
assert_eq!(sample_rect(1e300, 3.0, 3, canvas), None);
assert_eq!(sample_rect(3.0, 3.0, 4, canvas), None);
assert_eq!(sample_rect(3.0, 3.0, 0, canvas), None);
// At the i32 limits the square saturates rather than overflowing.
let huge = Rect::new(i32::MAX - 4, i32::MAX - 4, i32::MAX, i32::MAX);
assert_eq!(sample_rect(f64::from(i32::MAX - 1), f64::from(i32::MAX - 1), 101, huge), Some(huge));
}
#[test]
fn average_weights_by_alpha_and_ignores_garbage() {
let px = [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]];
let a = average(&px, ColorMode::Rgb, SampleType::F32);
assert!(close(a, [0.5, 0.0, 0.5, 0.5], 1e-6), "{a:?}");
// Half-transparent red next to opaque blue: red counts half.
let a = average(&[[1.0, 0.0, 0.0, 0.5], [0.0, 0.0, 1.0, 1.0]], ColorMode::Rgb, SampleType::F32);
assert!(close(a, [1.0 / 3.0, 0.0, 2.0 / 3.0, 0.75], 1e-6), "{a:?}");
assert_eq!(average(&[], ColorMode::Rgb, SampleType::U8), [0.0; 4]);
assert_eq!(average(&[[0.3, 0.3, 0.3, 0.0]], ColorMode::Rgb, SampleType::U8), [0.0; 4]);
}
#[test]
fn average_rounds_to_the_document_depth() {
// 0, 0.1, 1 averages to 0.3667: 94/255 at 8 bit, 24030/65535 at 16 bit, the float at 32.
let px = [[0.0, 0.0, 0.0, 1.0], [0.1, 0.1, 0.1, 1.0], [1.0, 1.0, 1.0, 1.0]];
let a8 = average(&px, ColorMode::Rgb, SampleType::U8);
assert_eq!(a8[0], 94.0 / 255.0);
let a16 = average(&px, ColorMode::Rgb, SampleType::U16);
assert_eq!(a16[0], 24030.0 / 65535.0);
let a32 = average(&px, ColorMode::Rgb, SampleType::F32);
assert!((a32[0] - 1.1 / 3.0).abs() < 1e-6);
assert!(a8[0] != a32[0] && a16[0] != a32[0]);
}
#[test]
fn average_is_taken_in_the_document_colour_model() {
// Gray: the mean of the gray values.
let a = average(&[[0.2, 0.2, 0.2, 1.0], [0.6, 0.6, 0.6, 1.0]], ColorMode::Grayscale, SampleType::F32);
assert!(close(a, [0.4, 0.4, 0.4, 1.0], 1e-5), "{a:?}");
// CMYK and Lab: the mean of the CMYK / Lab values, not of the RGB ones.
let (red, blue) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
for mode in [ColorMode::Cmyk, ColorMode::Lab] {
let fmt = PixelFormat::new(mode, SampleType::F32, false);
let (r, b) = (photocraft_raster::from_rgba(&fmt, red), photocraft_raster::from_rgba(&fmt, blue));
let mean: Vec<f32> = r.iter().zip(&b).map(|(x, y)| (x + y) / 2.0).collect();
let want = photocraft_raster::to_rgba(&fmt, &mean);
let got = average(&[red, blue], mode, SampleType::F32);
assert!(close(got, want, 1e-5), "{mode:?}: {got:?} vs {want:?}");
assert!(!close(got, [0.5, 0.0, 0.5, 1.0], 0.02), "{mode:?} averaged in RGB: {got:?}");
}
}
#[test]
fn averages_use_the_document_model_except_cmyk() {
let model = |mode| averaging_model(&Document::new("m", Size::new(1, 1), mode, SampleType::U8));
assert_eq!(model(ColorMode::Rgb), ColorMode::Rgb);
assert_eq!(model(ColorMode::Grayscale), ColorMode::Grayscale);
assert_eq!(model(ColorMode::Lab), ColorMode::Lab);
assert_eq!(model(ColorMode::Bitmap), ColorMode::Grayscale);
assert_eq!(model(ColorMode::Cmyk), ColorMode::Rgb);
}
/// A 9×9 document: white, except a black 3×3 block at (0..3, 0..3).
fn block(mode: ColorMode, depth: SampleType) -> Document {
doc(9, 9, mode, depth, |x, y| if x < 3 && y < 3 { [0.0, 0.0, 0.0, 1.0] } else { [1.0; 4] })
}
#[test]
fn sample_sizes_average_the_square_around_the_pixel_at_every_depth_and_mode() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for depth in SampleType::ALL {
let d = block(mode, depth);
let all = SampleLayers::All;
let tol = if depth == SampleType::U8 { 1.5 / 255.0 } else { 2e-3 };
// Point Sample: the composite pixel itself (black, as the document shows it).
let p = sample_color(&d, None, 1.5, 1.5, 1, all);
let white = sample_color(&d, None, 7.0, 7.0, 1, all);
assert!(p[0] < 0.05 && p[3] == 1.0, "{mode:?} {depth:?} point {p:?}");
// 3×3 at (3, 3): one black pixel of nine. Gray, RGB: 8/9 white.
let s = sample_color(&d, None, 3.5, 3.5, 3, all);
let want = sample_color(&d, None, 3.5, 3.5, 1, all);
assert!(s[0] < want[0] && s[3] == 1.0, "{mode:?} {depth:?} 3x3 {s:?}");
if matches!(mode, ColorMode::Rgb | ColorMode::Grayscale) {
assert!(close(s, [8.0 / 9.0, 8.0 / 9.0, 8.0 / 9.0, 1.0], tol), "{mode:?} {depth:?} 3x3 {s:?}");
}
// 5×5 at the corner: clipped to 3×3, all black.
let c = sample_color(&d, None, 0.0, 0.0, 5, all);
assert!(close(c, p, tol), "{mode:?} {depth:?} corner {c:?} vs {p:?}");
// 101×101 covers the canvas: 9 of 81 black.
if matches!(mode, ColorMode::Rgb | ColorMode::Grayscale) {
let w = sample_color(&d, None, 4.0, 4.0, 101, all);
assert!(close(w, [72.0 / 81.0, 72.0 / 81.0, 72.0 / 81.0, 1.0], tol), "{mode:?} {depth:?} 101 {w:?}");
}
// A uniform area averages to its own colour (round trips through the model).
let u = sample_color(&d, None, 7.0, 7.0, 3, all);
assert!(close(u, white, tol), "{mode:?} {depth:?} uniform {u:?} vs {white:?}");
// Off the canvas: nothing.
assert_eq!(sample_color(&d, None, -1.0, 4.0, 11, all), [0.0; 4]);
}
}
}
#[test]
fn current_and_below_keeps_an_active_groups_contents() {
// A white background and a group holding a red layer; the group is the active layer.
let mut d = doc(3, 3, ColorMode::Rgb, SampleType::U8, |_, _| [1.0; 4]);
let mut red = Layer::raster("red", d.pixel_format());
red.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 3, 3), &[1.0, 0.0, 0.0, 1.0]);
let group = Layer::group("group", vec![red]);
let gid = group.id;
d.layers.push(group);
for which in [SampleLayers::CurrentAndBelow, SampleLayers::CurrentAndBelowNoAdjustments] {
let c = sample_color(&d, Some(gid), 1.5, 1.5, 1, which);
assert!(close(c, [1.0, 0.0, 0.0, 1.0], 1.5 / 255.0), "{which:?}: {c:?}");
}
}
#[test]
fn sample_layers_choose_what_is_averaged() {
// Background white; a red 3x3 layer in the middle; an Invert adjustment on top.
let mut d = doc(9, 9, ColorMode::Rgb, SampleType::U8, |_, _| [1.0; 4]);
let bg = d.layers[0].id;
let mut red = Layer::raster("red", d.pixel_format());
red.surface_mut().unwrap().fill_rect(Rect::new(3, 3, 6, 6), &[1.0, 0.0, 0.0, 1.0]);
let red = d.insert_above(Some(bg), red);
let inv = d.insert_above(Some(red), Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert)));
let (cx, cy) = (4.5, 4.5);
// 5×5 around the centre: 9 red pixels of 25.
let s = |d: &Document, active, which| sample_color(d, active, cx, cy, 5, which);
let close8 = |a: [f32; 4], b: [f32; 4]| close(a, b, 1.5 / 255.0);
// Current Layer = the red layer alone: red, 9/25 covered.
let cur = s(&d, Some(red), SampleLayers::Current);
assert!(close8(cur, [1.0, 0.0, 0.0, 9.0 / 25.0]), "{cur:?}");
// Current & Below: red over white, not inverted.
let below = s(&d, Some(red), SampleLayers::CurrentAndBelow);
assert!(close8(below, [1.0, 16.0 / 25.0, 16.0 / 25.0, 1.0]), "{below:?}");
// All Layers: inverted (cyan over black).
let all = s(&d, Some(red), SampleLayers::All);
assert!(close8(all, [0.0, 9.0 / 25.0, 9.0 / 25.0, 1.0]), "{all:?}");
// No Adjustments: as if the Invert were hidden.
assert!(close8(s(&d, Some(red), SampleLayers::AllNoAdjustments), below));
assert!(close8(s(&d, Some(inv), SampleLayers::CurrentAndBelowNoAdjustments), below));
assert!(close8(s(&d, Some(inv), SampleLayers::CurrentAndBelow), all));
// Current Layer with an adjustment layer or no active layer: nothing to sample.
assert_eq!(s(&d, Some(inv), SampleLayers::Current), [0.0; 4]);
assert_eq!(s(&d, None, SampleLayers::Current), [0.0; 4]);
assert_eq!(s(&d, None, SampleLayers::CurrentAndBelow), [0.0; 4]);
// Current Layer ignores how the composite shows the layer: hidden, 50% opacity, Multiply.
let l = d.layer_mut(red).unwrap();
l.visible = false;
l.opacity = 0.5;
l.blend = photocraft_color::BlendMode::Multiply;
assert!(close8(s(&d, Some(red), SampleLayers::Current), cur));
}
#[test]
fn the_command_validates_its_params() {
let mut s = Session::new();
assert!(s.execute("document.sampleColor", json!({"x": 1, "y": 1})).is_err(), "no document");
s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
let v: Vec<f32> = serde_json::from_value(s.execute("document.sampleColor", json!({"x": 4, "y": 4, "size": 3})).unwrap()).unwrap();
assert_eq!(v, vec![1.0; 4]);
let v: Vec<f32> =
serde_json::from_value(s.execute("document.sampleColor", json!({"x": 4, "y": 4, "size": "point", "sampleLayer": "current"})).unwrap()).unwrap();
assert_eq!(v, vec![1.0; 4]);
let v: Vec<f32> = serde_json::from_value(s.execute("document.sampleColor", json!({"x": -40, "y": 4, "size": 101})).unwrap()).unwrap();
assert_eq!(v, vec![0.0; 4]);
for bad in [
json!({}),
json!({"x": 1}),
json!({"x": "a", "y": 1}),
json!({"x": 1, "y": 1, "size": 4}),
json!({"x": 1, "y": 1, "size": -3}),
json!({"x": 1, "y": 1, "size": "huge"}),
json!({"x": 1, "y": 1, "size": 1e40}),
json!({"x": 1, "y": 1, "sampleLayer": "below"}),
json!({"x": 1, "y": 1, "sampleLayer": 3}),
json!([1, 2]),
] {
assert!(matches!(s.execute("document.sampleColor", bad.clone()), Err(EngineError::BadParams { .. })), "{bad}");
}
}
}
+36
View File
@@ -733,3 +733,39 @@ fn convert_to_layers_conforms_the_contents_to_the_document_mode_and_depth() {
let fmt = s.active().unwrap().doc.pixel_format();
assert!(matches!(&active_layer(&s).content, LayerContent::Raster(px) if px.format().mode == fmt.mode && px.format().sample == fmt.sample));
}
#[test]
fn inspect_reports_the_smart_source() {
// A PSD placed layer: its file in the global `lnk2` block, the layer linked to its uuid.
let inner = Document::with_background(
"in",
Size::new(4, 4),
photocraft_color::ColorMode::Rgb,
photocraft_color::SampleType::U8,
photocraft_color::Color::rgba(0.0, 1.0, 0.0, 1.0),
);
let png = photocraft_io::export(&inner, "png", &Default::default()).unwrap().bytes;
let lnk2 = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile { uuid: "uuid-1".into(), file_name: "in.png".into(), bytes: png });
let mut s = session(8);
s.edit("place", |doc, active| {
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(lnk2)));
let sm = SmartObject::new(SmartSource::Linked { path: "uuid-1".into() }, Affine::IDENTITY, None);
*active = Some(doc.insert_above(*active, Layer::new("front", LayerContent::Smart(sm))));
Ok(())
})
.unwrap();
let source = |s: &Session| crate::inspect::layer(s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap())["smartSource"].clone();
let front = source(&s);
assert_eq!(front, json!({"kind": "linked", "path": "uuid-1"}));
// A duplicate shares the file; New Smart Object via Copy gets its own.
s.execute("layer.duplicate", json!({})).unwrap();
assert_eq!(source(&s), front, "a duplicate shares its source");
s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({})).unwrap();
assert_eq!(source(&s), json!({"kind": "embedded", "fileName": "in.png"}), "via copy embeds its own");
let mut s = session(8);
paint(&mut s);
convert(&mut s);
assert_eq!(source(&s)["kind"], "embedded");
assert!(source(&s)["fileName"].is_string());
}
@@ -0,0 +1,84 @@
//! #1691: a camera JPEG with its resolution only in EXIF opens at that resolution, and a JPEG
//! saved after Image Size (resolution only) carries the new resolution in JFIF and EXIF alike.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType, exif_resolution};
use photocraft_engine::Session;
use photocraft_engine::file_cmds::open_bytes_as;
use serde_json::json;
/// Little-endian EXIF: Make "NIKON", XResolution 300/1, YResolution 300/1, ResolutionUnit 2.
fn camera_exif() -> Vec<u8> {
let mut v = b"II*\0".to_vec();
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&4u16.to_le_bytes());
let data = 8 + 2 + 4 * 12 + 4;
let entry = |v: &mut Vec<u8>, tag: u16, ty: u16, count: u32, value: u32| {
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&ty.to_le_bytes());
v.extend_from_slice(&count.to_le_bytes());
v.extend_from_slice(&value.to_le_bytes());
};
entry(&mut v, 0x010F, 2, 6, data as u32);
entry(&mut v, 282, 5, 1, data as u32 + 6);
entry(&mut v, 283, 5, 1, data as u32 + 14);
entry(&mut v, 296, 3, 1, 2);
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(b"NIKON\0");
for _ in 0..2 {
v.extend_from_slice(&300u32.to_le_bytes());
v.extend_from_slice(&1u32.to_le_bytes());
}
v
}
/// A camera-style JPEG: no JFIF header, the resolution only in EXIF.
fn camera_jpeg() -> Vec<u8> {
let meta = Metadata { exif: Some(camera_exif()), ..Default::default() };
let img = Image::from_raw(16, 8, ChannelLayout::Rgb, SampleType::U8, vec![120; 16 * 8 * 3]).unwrap().with_meta(meta);
let b = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
assert_eq!(&b[2..4], &[0xFF, 0xE0], "the encoder starts with JFIF");
let len = u16::from_be_bytes([b[4], b[5]]) as usize;
[&b[..2], &b[4 + len..]].concat()
}
/// (marker, payload) of each APPn segment.
fn app_segments(b: &[u8]) -> Vec<(u8, Vec<u8>)> {
let mut out = Vec::new();
let mut i = 2;
while i + 4 <= b.len() && b[i] == 0xFF && b[i + 1] != 0xDA {
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
if (0xE0..=0xEF).contains(&b[i + 1]) {
out.push((b[i + 1], b[i + 4..i + 2 + len].to_vec()));
}
i += 2 + len;
}
out
}
#[test]
fn exif_resolution_opens_and_saves_consistently() {
let src = camera_jpeg();
assert!(app_segments(&src).iter().all(|(m, _)| *m != 0xE0));
let mut s = Session::new();
open_bytes_as(&mut s, "DSC_0001.JPG", &src, None, None).unwrap();
assert_eq!(s.active().unwrap().doc.resolution_dpi, 300.0);
s.execute("image.imageSize", json!({"resolution": 240, "resample": "none"})).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!((d.resolution_dpi, d.size.width, d.size.height), (240.0, 16, 8));
let dir = std::env::temp_dir().join(format!("photocraft-1691-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("saved.jpg");
s.execute("file.saveACopy", json!({"path": out.to_string_lossy()})).unwrap();
let saved = std::fs::read(&out).unwrap();
println!("saved {}", out.display());
let segs = app_segments(&saved);
let (_, jfif) = segs.iter().find(|(m, p)| *m == 0xE0 && p.starts_with(b"JFIF\0")).unwrap();
assert_eq!((jfif[7], u16::from_be_bytes([jfif[8], jfif[9]]), u16::from_be_bytes([jfif[10], jfif[11]])), (1, 240, 240));
let (_, exif) = segs.iter().find(|(m, p)| *m == 0xE1 && p.starts_with(b"Exif\0\0")).unwrap();
assert_eq!(exif_resolution(exif), Some((240.0, 240.0)));
assert!(exif.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept");
assert_eq!(photocraft_io::import("saved.jpg", &saved).unwrap().document.resolution_dpi, 240.0);
}
+5 -3
View File
@@ -1271,12 +1271,14 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -
if let Some(groups) = link_group_resource(&doc.layers) {
resources.push(ImageResource::new(ids::LAYER_GROUP_INFO, groups));
}
// The pixels are saved as they are shown: never let a reader rotate them again. The XMP and
// EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it (#1691).
let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0);
if let Some(x) = &doc.metadata.xmp {
// The pixels are saved as they are shown: never let a reader rotate them again.
resources.push(ImageResource::new(ids::XMP, photocraft_codecs::upright_xmp(x).as_bytes().to_vec()));
resources.push(ImageResource::new(ids::XMP, photocraft_codecs::export_xmp(x, ppi).as_bytes().to_vec()));
}
if let Some(e) = &doc.metadata.exif {
resources.push(ImageResource::new(ids::EXIF, photocraft_codecs::upright_exif(e).into_owned()));
resources.push(ImageResource::new(ids::EXIF, photocraft_codecs::export_exif(e, ppi).into_owned()));
}
let mut global_blocks = Vec::new();
for (sig, key, data) in &ex.smart.finish(crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc))) {
+44
View File
@@ -0,0 +1,44 @@
//! #1691: the EXIF resolution of an imported photo becomes the document's, and a PSD save keeps
//! its EXIF copy in step with the ResolutionInfo resource.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType, exif_resolution};
use photocraft_io::*;
/// Big-endian EXIF: XResolution 300/1, YResolution 300/1, ResolutionUnit 2.
fn camera_exif() -> Vec<u8> {
let mut v = b"MM\0*".to_vec();
v.extend_from_slice(&8u32.to_be_bytes());
v.extend_from_slice(&3u16.to_be_bytes());
let data = 8 + 2 + 3 * 12 + 4;
for (tag, ty, value) in [(282u16, 5u16, data as u32), (283, 5, data as u32 + 8)] {
v.extend_from_slice(&tag.to_be_bytes());
v.extend_from_slice(&ty.to_be_bytes());
v.extend_from_slice(&1u32.to_be_bytes());
v.extend_from_slice(&value.to_be_bytes());
}
v.extend_from_slice(&296u16.to_be_bytes());
v.extend_from_slice(&3u16.to_be_bytes());
v.extend_from_slice(&1u32.to_be_bytes());
v.extend_from_slice(&[0, 2, 0, 0]);
v.extend_from_slice(&0u32.to_be_bytes());
for _ in 0..2 {
v.extend_from_slice(&300u32.to_be_bytes());
v.extend_from_slice(&1u32.to_be_bytes());
}
v
}
#[test]
fn webp_with_exif_resolution_imports_at_it_and_psd_save_follows_the_document() {
let meta = Metadata { exif: Some(camera_exif()), ..Default::default() };
let img = Image::from_raw(6, 4, ChannelLayout::Rgb, SampleType::U8, vec![50; 72]).unwrap().with_meta(meta);
let webp = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions::default()).unwrap();
let mut doc = import("photo.webp", &webp).unwrap().document;
assert_eq!(doc.resolution_dpi, 300.0);
doc.resolution_dpi = 240.0;
let psd = export(&doc, "photo.psd", &ExportOptions::default()).unwrap().bytes;
let back = import("photo.psd", &psd).unwrap().document;
assert_eq!(back.resolution_dpi, 240.0);
assert_eq!(back.metadata.exif.as_deref().and_then(|e| exif_resolution(e)), Some((240.0, 240.0)));
}
+1 -1
View File
@@ -11,7 +11,7 @@ default = []
# Synthetic raw-file generators (DNG / CR2 / TIFF-EP writers and a lossless-JPEG
# encoder) for tests and fuzz seeds.
testgen = []
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`), defined with the corpus PR.
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
corpus = []
[dependencies]
+37
View File
@@ -0,0 +1,37 @@
//! Prints the raw metadata of files: `cargo run -p photocraft-raw --example inspect -- a.cr2 b.nef`
//! (handy while adding corpus files; the oracle expectations in `tests/raw_corpus.rs` come from it).
use std::env;
fn main() {
for path in env::args().skip(1) {
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
println!("{path}: read error {e}");
continue;
}
};
match photocraft_raw::decode(&bytes, &photocraft_raw::Limits::default()) {
Ok(s) => {
println!(
"{path}: {:?} make={:?} model={:?} {}x{} samples={} cfa={:?} wb={:?} black={:?} white={:?} crop={:?}..{:?} orientation={} warnings={}",
s.format,
s.make,
s.model,
s.width,
s.height,
s.samples,
s.cfa,
s.camera_wb,
s.black,
s.white,
s.active,
s.crop,
s.orientation,
s.warnings.len(),
);
}
Err(e) => println!("{path}: decode error {e}"),
}
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Real-camera corpus tests against `corpus/pixls` (raw.pixls.us, public domain): one file per
//! decode path, oracle-checked against values measured when the corpus was pinned (the
//! `inspect` example prints them). Opt-in like every real-file corpus:
//! `cargo xtask corpus --pixls` fetches, `cargo xtask test-corpus` runs.
#![cfg(feature = "corpus")]
use photocraft_raw::{Limits, RawError, RawFormat};
use std::path::PathBuf;
fn pixls(name: &str) -> PathBuf {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pixls").join(name);
if !p.is_file() {
panic!("{} is missing: run `cargo xtask corpus --pixls` (or `--all`) first", p.display());
}
p
}
struct Want<'a> {
file: &'static str,
format: RawFormat,
make: &'static str,
model: &'static str,
/// Sensor width and height.
size: (usize, usize),
/// The four CFA colours, row-major.
cfa: [u8; 4],
black: &'a [f32],
white: [f32; 3],
}
const WANTS: &[Want] = &[
Want {
file: "IMG_4059.CR2",
format: RawFormat::Cr2,
make: "Canon",
model: "Canon PowerShot SX50 HS",
size: (4176, 3062),
cfa: [0, 1, 1, 2],
black: &[128.0; 4],
white: [4095.0; 3],
},
// The same camera after DNG Converter: the sensor data round-trips identically.
Want {
file: "CRW_4061.DNG",
format: RawFormat::Dng,
make: "Canon",
model: "Canon PowerShot SX50 HS",
size: (4176, 3062),
cfa: [0, 1, 1, 2],
black: &[127.0],
white: [4095.0; 3],
},
Want {
file: "JD1_8203.NEF",
format: RawFormat::Nef,
make: "NIKON CORPORATION",
model: "NIKON D3",
size: (4288, 2844),
cfa: [0, 1, 1, 2],
black: &[0.0],
white: [4095.0; 3],
},
// Sony cRAW: the saturation level comes from the compressed code's range.
Want {
file: "DSC00009.ARW",
format: RawFormat::Arw,
make: "SONY",
model: "DSC-RX0",
size: (4832, 3224),
cfa: [0, 1, 1, 2],
black: &[0.0],
white: [16301.0; 3],
},
// The G9's high-resolution mode: a 10480 × 7794 sensor cropped to 10368 × 7776.
Want {
file: "P1000475.RW2",
format: RawFormat::Rw2,
make: "Panasonic",
model: "DC-G9",
size: (10480, 7794),
cfa: [0, 1, 1, 2],
black: &[127.0, 128.0, 128.0, 127.0],
white: [4095.0; 3],
},
// Four Thirds Olympus: GRBG instead of the usual RGGB.
Want {
file: "E_1__C106743_gredos.ORF",
format: RawFormat::Orf,
make: "OLYMPUS CORPORATION",
model: "E-1",
size: (2624, 1966),
cfa: [1, 0, 2, 1],
black: &[65.0; 4],
white: [4095.0; 3],
},
];
#[test]
fn decode_matches_the_measured_oracles() {
for w in WANTS {
let bytes = std::fs::read(pixls(w.file)).unwrap_or_else(|e| panic!("{}: {e}", w.file));
let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap_or_else(|e| panic!("{}: {e}", w.file));
assert_eq!(s.format, w.format, "{}", w.file);
assert_eq!(s.make.as_deref(), Some(w.make), "{}", w.file);
assert_eq!(s.model.as_deref(), Some(w.model), "{}", w.file);
assert_eq!((s.width, s.height), w.size, "{}", w.file);
let cfa = s.cfa.as_ref().unwrap_or_else(|| panic!("{}: no CFA", w.file));
assert_eq!(cfa.colors, w.cfa, "{}", w.file);
assert_eq!(s.black.values, w.black, "{}", w.file);
for ch in 0..3 {
assert!((s.white[ch] - w.white[ch]).abs() < 0.5, "{} white[{ch}] = {}", w.file, s.white[ch]);
}
}
}
#[test]
fn developing_produces_the_crop_in_16bit_rgb() {
for w in WANTS {
let bytes = std::fs::read(pixls(w.file)).unwrap_or_else(|e| panic!("{}: {e}", w.file));
let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap_or_else(|e| panic!("{}: {e}", w.file));
let d = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap_or_else(|e| panic!("{}: {e}", w.file));
assert_eq!((d.width as usize, d.height as usize), (s.crop.width, s.crop.height), "{}", w.file);
assert_eq!(d.rgb.len(), d.width as usize * d.height as usize * 3, "{}", w.file);
// A real exposure covers a sane share of the range and never leaves it.
let (mut lo, mut hi) = (u16::MAX, 0u16);
for v in &d.rgb {
lo = (*v).min(lo);
hi = (*v).max(hi);
}
// A real exposure covers a sane share of the range and never leaves it. The PowerShot
// CR2 and the RX0 ARW are the documented exceptions: neither carries an as-shot white
// balance we read, so the grey-world estimate brightens these scenes (the PowerShot's
// own DNG, with its file WB, develops blacks to ~0 — reading the maker-note WB of
// Canon and Sony is the follow-up).
let estimated_wb = matches!(w.file, "IMG_4059.CR2" | "DSC00009.ARW");
let black_cap: u16 = if estimated_wb { 16384 } else { 4096 };
assert!(lo < black_cap, "{}: developed blacks at {lo}", w.file);
assert!(hi > u16::MAX / 4, "{}: developed highlights at {hi}", w.file);
// Deterministic: a second develop of the same sensor gives the same pixels.
let d2 = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap();
assert_eq!(d.rgb, d2.rgb, "{}", w.file);
}
}
/// The E-5 file is the documented known-unsupported case: later Olympus bodies store packed
/// (compressed) sensor data. The error must say so, never panic, and the file still previews.
#[test]
fn packed_orf_reports_the_documented_gap() {
let bytes = std::fs::read(pixls("_7061961_copy.ORF")).unwrap();
let err = photocraft_raw::decode(&bytes, &Limits::default()).unwrap_err();
assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("Olympus compressed")), "{err}");
assert!(photocraft_raw::embedded_preview(&bytes).is_some(), "the camera's JPEG preview is still reachable");
}
+2 -2
View File
@@ -120,11 +120,11 @@ pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Resu
"view.fitOnScreen" => v.fit_pending = true,
"view.zoomIn" => {
v.fit_pending = false;
v.zoom = crate::canvas::zoom_step(v.zoom, 1);
v.zoom = crate::zoom_levels::step(v.zoom, 1, v.doc_size);
}
"view.zoomOut" => {
v.fit_pending = false;
v.zoom = crate::canvas::zoom_step(v.zoom, -1);
v.zoom = crate::zoom_levels::step(v.zoom, -1, v.doc_size);
}
"view.actualPixels" => {
v.fit_pending = false;
+3 -1
View File
@@ -60,11 +60,13 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let id = st.active_layer.and_then(|l| st.doc.artboard_of(l)).or_else(|| st.doc.artboards().last().map(|b| b.0)).ok_or("the document has no artboards")?;
let b = st.doc.layer(id).and_then(Layer::artboard).map(|a| a.rect).ok_or("no artboard")?;
let size = [st.doc.size.width, st.doc.size.height];
let area = app.last_canvas_rect.size();
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
let v = &mut app.ui.views[i];
// Leave room for the name above the board.
v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32).clamp(0.01, 64.0);
v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32);
v.zoom = crate::zoom_levels::clamp(v.zoom, size);
// Widen before adding: a board near ±2^30, or one whose far edge saturated at i32::MAX,
// overflows an i32 sum (#981).
v.center = [((f64::from(b.x0) + f64::from(b.x1)) / 2.0) as f32, ((f64::from(b.y0) + f64::from(b.y1)) / 2.0) as f32];
+37 -27
View File
@@ -396,7 +396,7 @@ fn display_color(c: [f32; 3]) -> Color32 {
/// (what a click would pick) and the current foreground, or `None` where there is no colour
/// to sample (#213).
pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<(Color32, Color32)> {
let new = composite_color(app, x, y)?;
let new = eyedropper_color(app, x, y)?;
let fg = app.session.tools.foreground;
Some((display_color(new), display_color([fg[0], fg[1], fg[2]])))
}
@@ -407,7 +407,8 @@ pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) ->
/// crosshair and samples nothing. `None` when not held, there is nothing to sample, or the
/// Precise-cursor preference wants the plain crosshair.
fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, p: Pos2, held: bool) -> Option<egui::CursorIcon> {
if !held || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
// The options bar's Show Sampling Ring turns it off (#1649).
if !held || !app.ui.tool_options.eyedropper_ring || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
return None;
}
let [x, y] = xf.to_doc(p);
@@ -626,7 +627,7 @@ impl ViewXform {
pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
let zoom = ((area.x - 40.0) / w).min((area.y - 40.0) / h).clamp(0.01, 1.0);
let zoom = crate::zoom_levels::clamp(((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0), [doc.size.width, doc.size.height]);
view.zoom = zoom;
view.center = [w / 2.0, h / 2.0];
view.fit_pending = false;
@@ -637,20 +638,13 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
/// viewport, matching the Hand tool's Fill Screen action.
pub fn fill_view(view: &mut View, doc: &Document, area: Vec2) {
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
let zoom = (area.x / w).max(area.y / h).clamp(0.01, 32.0);
let zoom = crate::zoom_levels::clamp((area.x / w).max(area.y / h), [doc.size.width, doc.size.height]);
view.zoom = zoom;
view.center = [w / 2.0, h / 2.0];
view.fit_pending = false;
view.fill_pending = false;
}
/// Zoom steps like Photoshop's (⌘+ / ⌘−).
pub fn zoom_step(z: f32, dir: i32) -> f32 {
const STEPS: [f32; 22] =
[0.01, 0.02, 0.03, 0.05, 0.0667, 0.1, 0.125, 0.1667, 0.25, 0.333, 0.5, 0.6667, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0, 32.0];
if dir > 0 { STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(32.0) } else { STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.01) }
}
fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId {
// Preferences › Transparency & Gamut colours (the texture is rebuilt when they change).
let [ca, cb] = app.session.prefs().transparency_and_gamut.colors();
@@ -2208,8 +2202,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let pointer = ui.input(|i| i.pointer.hover_pos());
match (wheel, pointer) {
(Some(crate::wheel_nav::Wheel::Zoom(f)), Some(p)) => {
let nz = (view.zoom * f).clamp(0.01, 64.0);
zoom_about(&mut view, &xf, p, nz, false);
// At a limit the view stays put, as in Photoshop: a notch past 12800 % neither
// zooms nor slides the image towards the pointer.
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
if nz != view.zoom {
zoom_about(&mut view, &xf, p, nz, false);
}
}
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
let d = xf.unmap_vec(scroll) / view.zoom;
@@ -2492,7 +2490,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let d = xf.to_doc(p);
match tool {
Tool::Zoom => {
let nz = zoom_step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 });
let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);
let center = app.session.prefs().tools.zoom_clicked_point_to_center;
zoom_about(&mut view, &xf, p, nz, center);
}
@@ -3153,25 +3151,41 @@ fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
if let Some([r, g, b]) = composite_color(app, x, y) {
if let Some([r, g, b]) = eyedropper_color(app, x, y) {
let key = if mods.alt { "background" } else { "foreground" };
let _ = app.run("tools.setColors", json!({ key: [r, g, b, 1.0] }));
}
}
/// The active document's composite colour at document point (x, y): what the Eyedropper picks.
/// The colour at document point (x, y) with the Eyedropper's Sample Size (the composite pixel,
/// or the average of the square around it) from the layers `sample_layer` names (`document.sampleColor`).
/// `None` off the image or over transparency.
pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> {
fn sample_color(app: &mut PhotocraftApp, x: f64, y: f64, sample_layer: &str) -> Option<[f32; 3]> {
if !(x.is_finite() && y.is_finite()) {
return None;
}
let v = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})).ok()?;
let size = app.ui.tool_options.eyedropper_size;
let v = app.run("document.sampleColor", json!({"x": x, "y": y, "size": size, "sampleLayer": sample_layer})).ok()?;
match serde_json::from_value::<Vec<f32>>(v).ok()?[..] {
[r, g, b, a] if a > 0.0 => Some([r, g, b]),
_ => None,
}
}
/// What the Eyedropper tool (and a painting tool's ⌥-click) picks at document point (x, y): its
/// options bar's Sample Size and Sample (#1649).
pub(crate) fn eyedropper_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> {
let layers = app.ui.tool_options.eyedropper_sample.clone();
sample_color(app, x, y, &layers)
}
/// The active document's composite colour at document point (x, y), averaged over the
/// Eyedropper's Sample Size as Photoshop's dialog eyedroppers (Curves, Color Picker) do.
/// `None` off the image or over transparency.
pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<[f32; 3]> {
sample_color(app, x, y, "all")
}
/// The body of a `Move` for every tool: tracked position, ⇧ constraint, the moving layer, and so
/// on. It does not feed the live stroke, so the canvas can push a whole frame's recovered samples
/// and update the live stroke once (see `canvas_view`).
@@ -3650,7 +3664,11 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let o = &app.ui.tool_options;
// The Patch and Content-Aware Move tools have no Feather in their options bar.
let feather = if d.tool == Tool::Lasso { o.feather } else { 0.0 };
let _ = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": feather}));
let made = app.run("select.lasso", json!({"points": pts, "mode": mode, "antiAlias": o.anti_alias, "feather": feather}));
// The lasso only outlines the patch; say that it is dragged next (#1715).
if made.is_ok() && d.tool != Tool::Lasso {
crate::retouch_ui::patch_hint(app);
}
} else if app.session.is_enabled("select.deselect") {
let _ = app.run("select.deselect", json!({}));
}
@@ -4496,14 +4514,6 @@ mod tests {
assert!(app.style_preview.is_none());
}
#[test]
fn zoom_steps_monotone() {
assert_eq!(zoom_step(1.0, 1), 2.0);
assert_eq!(zoom_step(1.0, -1), 0.6667);
assert_eq!(zoom_step(0.4, 1), 0.5);
assert_eq!(zoom_step(32.0, 1), 32.0);
}
#[test]
fn fill_view_covers_the_canvas_and_centres_the_document() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+2 -2
View File
@@ -130,8 +130,8 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
};
let text = status_info_text(&st.doc, &app.ui.chrome.status_info, tl!(app.ui.tool.label()), &profile_name(&st.doc));
let mut pct = app.ui.views[i].zoom * 100.0;
if widgets::value_field(ui, &mut pct, 1.0..=3200.0, "%", 64.0).changed() {
app.ui.views[i].zoom = pct / 100.0;
if widgets::value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0).changed() {
app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size);
app.ui.views[i].fit_pending = false;
}
ui.add_space(12.0);
+63 -2
View File
@@ -74,7 +74,7 @@ pub enum Outcome {
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
/// changed (#412).
pub const UI_SET_FIELDS: [&str; 22] = [
pub const UI_SET_FIELDS: [&str; 25] = [
"tool",
"panels",
"dock",
@@ -97,6 +97,9 @@ pub const UI_SET_FIELDS: [&str; 22] = [
"brushSize",
"gradientBlendMode",
"gradientClassic",
"eyedropperSampleSize",
"eyedropperSample",
"eyedropperRing",
];
/// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app
@@ -303,6 +306,23 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
None => None,
};
let gradient_classic = bool_field(p, "gradientClassic")?;
// The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring.
let eyedropper_size = match p.get("eyedropperSampleSize") {
Some(v) => Some(
photocraft_engine::sample_cmds::size_param("ui.set", &json!({"size": v}))
.map_err(|_| format!("eyedropperSampleSize must be \"point\" or one of {:?}", photocraft_engine::sample_cmds::SAMPLE_SIZES))?,
),
None => None,
};
let eyedropper_sample = match p.get("eyedropperSample") {
Some(v) => {
let ids: Vec<&str> = photocraft_engine::sample_cmds::SampleLayers::ALL.iter().map(|(_, n)| *n).collect();
let id = v.as_str().filter(|id| ids.contains(id)).ok_or_else(|| format!("eyedropperSample must be one of {}", ids.join(", ")))?;
Some(id.to_string())
}
None => None,
};
let eyedropper_ring = bool_field(p, "eyedropperRing")?;
let panels = merged_object(&app.ui.panels, p.get("panels"), "panels")?;
let mask_target = bool_field(p, "maskTarget")?;
let vector_mask_target = bool_field(p, "vectorMaskTarget")?;
@@ -394,6 +414,15 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if gradient_blend.is_some() {
crate::gradient_ui::options_changed(app, &gradient_before);
}
if let Some(size) = eyedropper_size {
app.ui.tool_options.eyedropper_size = size;
}
if let Some(id) = eyedropper_sample {
app.ui.tool_options.eyedropper_sample = id;
}
if let Some(ring) = eyedropper_ring {
app.ui.tool_options.eyedropper_ring = ring;
}
if let Some(v) = panels {
app.ui.panels = v;
}
@@ -425,7 +454,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
}
if let Some(i) = app.session.active_index() {
if let Some(z) = zoom {
app.ui.views[i].zoom = (z as f32).clamp(0.01, 64.0);
let size = app.session.documents().get(i).map_or([0, 0], |st| [st.doc.size.width, st.doc.size.height]);
app.ui.views[i].zoom = crate::zoom_levels::clamp(z as f32, size);
app.ui.views[i].fit_pending = false;
app.ui.views[i].fill_pending = false;
}
@@ -1031,6 +1061,37 @@ mod tests {
assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options");
}
#[test]
fn ui_set_drives_the_eyedropper_options() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let good = call(
&mut app,
&ctx,
"ui.set",
json!({"tool": "eyedropper", "eyedropperSampleSize": 11, "eyedropperSample": "currentAndBelow", "eyedropperRing": false}),
);
assert_eq!(good["ok"], true, "{good}");
let o = &app.ui.tool_options;
assert_eq!((o.eyedropper_size, o.eyedropper_sample.as_str(), o.eyedropper_ring), (11, "currentAndBelow", false));
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"eyedropperSampleSize": "point"}))["ok"], true);
assert_eq!(app.ui.tool_options.eyedropper_size, 1);
for bad in [
json!({"eyedropperSampleSize": 4}),
json!({"eyedropperSampleSize": "big"}),
json!({"eyedropperSample": "below"}),
json!({"eyedropperSample": 2}),
json!({"eyedropperRing": "yes"}),
] {
let r = call(&mut app, &ctx, "ui.set", bad.clone());
assert_eq!(r["ok"], false, "{bad}: {r}");
}
// A rejected call applies none of its fields.
let r = call(&mut app, &ctx, "ui.set", json!({"eyedropperSampleSize": 101, "eyedropperSample": "nope"}));
assert_eq!(r["ok"], false, "{r}");
assert_eq!(app.ui.tool_options.eyedropper_size, 1);
}
#[test]
fn ui_set_opens_the_brush_preset_picker_and_sets_its_view() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+203
View File
@@ -0,0 +1,203 @@
//! The Eyedropper tool's options bar (#1649): Sample Size (Point Sample or an N by N Average),
//! Sample (which layers) and Show Sampling Ring, as in Photoshop. The values live in
//! `state::ToolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`), so the
//! control channel can read and set them; sampling itself is `document.sampleColor`
//! (`canvas::eyedropper_color`). The Sample Size is shared with the painting tools' ⌥-click
//! eyedropper, the dialog eyedroppers and the Info panel, as in Photoshop.
use crate::PhotocraftApp;
use crate::theme::Tokens;
use crate::widgets;
/// The Sample Size menu: each size with its label, in Photoshop's order.
pub fn size_options() -> [(u32, &'static str); 7] {
[
(1, tl!("Point Sample")),
(3, tl!("3 by 3 Average")),
(5, tl!("5 by 5 Average")),
(11, tl!("11 by 11 Average")),
(31, tl!("31 by 31 Average")),
(51, tl!("51 by 51 Average")),
(101, tl!("101 by 101 Average")),
]
}
/// The Sample menu: `document.sampleColor`'s `sampleLayer` ids with their labels.
pub fn layer_options() -> [(String, &'static str); 5] {
[
("current".into(), tl!("Current Layer")),
("currentAndBelow".into(), tl!("Current & Below")),
("all".into(), tl!("All Layers")),
("allNoAdjustments".into(), tl!("All Layers No Adjustments")),
("currentAndBelowNoAdjustments".into(), tl!("Current & Below No Adjustments")),
]
}
fn opt(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).color(t.text_dim).size(12.0));
}
/// The options bar for the Eyedropper tool.
pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let o = &mut app.ui.tool_options;
opt(ui, tl!("Sample Size:"));
widgets::dropdown(ui, "eyedropper-size", &mut o.eyedropper_size, &size_options(), 130.0);
opt(ui, tl!("Sample:"));
widgets::dropdown(ui, "eyedropper-sample", &mut o.eyedropper_sample, &layer_options(), 190.0);
widgets::checkbox(ui, &mut o.eyedropper_ring, tl!("Show Sampling Ring"));
widgets::vline(ui, 22.0);
let t = Tokens::get(ui.ctx());
ui.label(
egui::RichText::new(crate::i18n::fmt(
tl!("Click to sample the foreground colour · {key}-click for background"),
&[("key", &crate::shortcuts::pretty("Alt"))],
))
.color(t.text_faint),
);
}
#[cfg(test)]
mod tests {
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
/// An app with a 40×40 white document whose top-left 3×3 pixels are black.
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 40, "height": 40, "background": "white"})).unwrap();
app.run("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
app.session
.edit("paint", |doc, _| {
let bg = doc.layers[0].surface_mut().unwrap();
let black = photocraft_raster::from_rgba(&bg.format(), [0.0, 0.0, 0.0, 1.0]);
bg.fill_rect(photocraft_geom::Rect::new(0, 0, 3, 3), &black);
Ok(())
})
.unwrap();
app.ui.tool = Tool::Eyedropper;
app
}
fn fg(app: &PhotocraftApp) -> [f32; 4] {
app.session.tools.foreground
}
fn click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods);
tool_event(app, ToolEvent::Up { x, y }, mods);
}
#[test]
fn a_click_samples_the_average_of_the_sample_size() {
let mut app = app();
// Point Sample at (3, 3): white.
click(&mut app, 3.5, 3.5, egui::Modifiers::NONE);
assert_eq!(fg(&app), [1.0; 4]);
// 3 by 3 Average: one black pixel of nine, rounded to 8 bit.
app.ui.tool_options.eyedropper_size = 3;
click(&mut app, 3.5, 3.5, egui::Modifiers::NONE);
let want = (255.0f32 * 8.0 / 9.0).round() / 255.0;
assert_eq!(fg(&app), [want, want, want, 1.0]);
// 5 by 5 at the corner: clipped to the black 3×3.
app.ui.tool_options.eyedropper_size = 5;
click(&mut app, 0.5, 0.5, egui::Modifiers::NONE);
assert_eq!(fg(&app), [0.0, 0.0, 0.0, 1.0]);
// ⌥-click sets the background colour, with the same size.
app.ui.tool_options.eyedropper_size = 3;
click(&mut app, 3.5, 3.5, egui::Modifiers::ALT);
assert_eq!(app.session.tools.background, [want, want, want, 1.0]);
// The painting tools' ⌥-click eyedropper uses it too.
app.ui.tool = Tool::Brush;
app.ui.tool_options.eyedropper_size = 101;
click(&mut app, 20.0, 20.0, egui::Modifiers::ALT);
let want = (255.0f32 * (1600.0 - 9.0) / 1600.0).round() / 255.0;
assert_eq!(fg(&app), [want, want, want, 1.0]);
// The dialog eyedroppers (and the Color Picker's) average the same square.
app.ui.tool_options.eyedropper_size = 5;
let c = crate::canvas::composite_color(&mut app, 1.0, 1.0).unwrap();
let want = (255.0f32 * 7.0 / 16.0).round() / 255.0;
assert_eq!(c, [want; 3]);
}
#[test]
fn the_sample_menu_picks_the_layers() {
let mut app = app();
// A new empty layer on top: Current Layer finds nothing; All Layers the background.
app.run("layer.new.layer", json!({})).unwrap();
app.ui.tool_options.eyedropper_size = 3;
app.ui.tool_options.eyedropper_sample = "current".into();
app.run("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 1.0]})).unwrap();
click(&mut app, 10.0, 10.0, egui::Modifiers::NONE);
assert_eq!(fg(&app), [0.2, 0.4, 0.6, 1.0], "nothing to sample on an empty layer");
app.ui.tool_options.eyedropper_sample = "all".into();
click(&mut app, 10.0, 10.0, egui::Modifiers::NONE);
assert_eq!(fg(&app), [1.0; 4]);
}
#[test]
fn tool_options_round_trip_through_serde_with_defaults() {
let o = crate::state::ToolOptions::default();
assert_eq!((o.eyedropper_size, o.eyedropper_sample.as_str(), o.eyedropper_ring), (1, "all", true));
// Older saved state without the fields loads with Photoshop's defaults.
let old: crate::state::ToolOptions = serde_json::from_value(json!({"tolerance": 12.0})).unwrap();
assert_eq!((old.eyedropper_size, old.eyedropper_sample.as_str(), old.eyedropper_ring), (1, "all", true));
let mut o = o;
o.eyedropper_size = 31;
o.eyedropper_sample = "currentAndBelow".into();
o.eyedropper_ring = false;
let back: crate::state::ToolOptions = serde_json::from_value(serde_json::to_value(&o).unwrap()).unwrap();
assert_eq!(back, o);
// Every menu entry is a size and a layer choice the engine accepts.
for (size, _) in super::size_options() {
assert!(photocraft_engine::sample_cmds::SAMPLE_SIZES.contains(&size));
}
for (id, _) in super::layer_options() {
assert!(photocraft_engine::sample_cmds::SampleLayers::parse(&id).is_some(), "{id}");
}
}
/// The options bar: the Sample Size dropdown sets the size, and the next click on the canvas
/// samples that average.
#[test]
fn the_options_bar_dropdown_sets_the_sample_size() {
let mut h = Harness::builder().with_size(vec2(1400.0, 600.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::options_bar(app, ui);
},
app(),
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(4);
for label in ["Sample Size:", "Sample:", "Show Sampling Ring"] {
assert!(h.get_by_label(label).rect().is_positive(), "missing Eyedropper option {label}");
}
let combo = |h: &Harness<'static, PhotocraftApp>, i: usize| h.get_all_by_role(egui::accesskit::Role::ComboBox).nth(i).unwrap().click();
combo(&h, 0);
h.run_steps(3);
h.get_by_label("5 by 5 Average").click();
h.run_steps(3);
assert_eq!(h.state().ui.tool_options.eyedropper_size, 5);
combo(&h, 1);
h.run_steps(3);
h.get_by_label("Current Layer").click();
h.run_steps(3);
assert_eq!(h.state().ui.tool_options.eyedropper_sample, "current");
h.get_by_label("Show Sampling Ring").click();
h.run_steps(3);
assert!(!h.state().ui.tool_options.eyedropper_ring);
// A click at (1, 3) now averages the 5×5 around it: rows 1..6 × cols 0..4 hold 6 black.
click(h.state_mut(), 1.5, 3.5, egui::Modifiers::NONE);
let want = (255.0f32 * 14.0 / 20.0).round() / 255.0;
assert_eq!(fg(h.state()), [want, want, want, 1.0]);
}
}
+86
View File
@@ -0,0 +1,86 @@
//! #1719: layer effects are dragged onto another layer in the Layers panel, as in Photoshop: an
//! effect row or the fx badge moves its effects there, ⌥-drag copies them.
use egui::{Modifiers, PointerButton, Pos2, Rect, accesskit::Role, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use photocraft_doc::LayerId;
use serde_json::json;
use crate::PhotocraftApp;
use crate::layer_row_ui::{Indicator, RowRects, recorded};
/// `a` with a Drop Shadow and a Color Overlay, and `b` above it without effects.
fn harness() -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000"})).unwrap();
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(s, crate::Services::default())
});
h.run_steps(8);
(h, LayerId(a), LayerId(b))
}
fn row(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> RowRects {
recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap()
}
/// The effect row `name` under layer `id`'s row.
fn effect_row(h: &Harness<'_, PhotocraftApp>, id: LayerId, name: &str) -> Rect {
let layer = row(h, id).row;
h.query_all_by_role(Role::Button)
.filter(|n| n.accesskit_node().label().as_deref() == Some(name))
.map(|n| n.rect())
.find(|r| r.top() >= layer.bottom() - 1.0 && r.left() < layer.center().x && r.right() > layer.center().x)
.unwrap_or_else(|| panic!("no {name} row under the layer"))
}
fn effects(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> Vec<&'static str> {
h.state().session.active().unwrap().doc.layer(id).unwrap().effects.items.iter().map(photocraft_doc::Effect::label).collect()
}
/// A real press, drag and release, `modifiers` held throughout.
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.hover_at(from);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
for i in 1..=6 {
h.hover_at(from + (to - from) * (i as f32 / 6.0));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
#[test]
fn dragging_effects_onto_a_layer_moves_them_and_alt_copies() {
let (mut h, a, b) = harness();
assert_eq!(effects(&h, a), ["Drop Shadow", "Color Overlay"]);
let steps = h.state().session.active().unwrap().history.past_len();
// ⌥-drag one effect row onto `b`: just that effect is copied.
let from = effect_row(&h, a, "Color Overlay").center();
let to = row(&h, b).row.center();
drag(&mut h, from, to, Modifiers::ALT);
assert_eq!(effects(&h, a), ["Drop Shadow", "Color Overlay"]);
assert_eq!(effects(&h, b), ["Color Overlay"]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step");
// Drag the fx badge onto `b`: all of `a`'s effects move there, replacing `b`'s.
let badge = row(&h, a).indicators.into_iter().find(|(k, _)| *k == Indicator::Fx).unwrap().1;
let to = row(&h, b).row.center();
drag(&mut h, badge.center(), to, Modifiers::NONE);
assert!(effects(&h, a).is_empty(), "the effects moved off `a`");
assert_eq!(effects(&h, b), ["Drop Shadow", "Color Overlay"]);
// The drag moved effects, not the layer: `b` is still above `a`.
assert!(row(&h, b).row.top() < row(&h, a).row.top());
}
+2
View File
@@ -276,6 +276,8 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result<Value, String> {
}
match ui.get("zoom") {
Some(Value::String(s)) if s == "fit" => d.zoom = 0.0,
// The gallery's own preview zoom, not the canvas (`zoom_levels` doesn't apply): its − / +
// buttons and menu stop at 400 %, an agent may set up to 3200 %.
Some(v) if v.is_number() => d.zoom = (v.as_f64().unwrap_or(1.0) as f32).clamp(0.01, 32.0),
_ => {}
}
+14
View File
@@ -2094,6 +2094,7 @@ cameraRaw Color Barvy
Patch Tool Nástroj záplata
Patch: Záplata:
Lasso around an area, then drag the selection Obtáhněte oblast lasem a pak přetáhněte výběr
Now drag the selection onto another area Nyní přetáhněte výběr na jiné místo
Edit Type Upravit text
@@ -2249,6 +2250,19 @@ fontWeight Italic Kurzíva
Reset View Obnovit zobrazení
Drag around the centre to rotate the view · Shift constrains to 15° Tažením kolem středu otočíte zobrazení · Shift omezí na 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Bodový vzorek
3 by 3 Average Průměr 3 × 3
5 by 5 Average Průměr 5 × 5
11 by 11 Average Průměr 11 × 11
31 by 31 Average Průměr 31 × 31
51 by 51 Average Průměr 51 × 51
101 by 101 Average Průměr 101 × 101
Sample Size: Velikost vzorku:
All Layers No Adjustments Všechny vrstvy bez korekcí
Current & Below No Adjustments Aktuální a pod ní bez korekcí
Show Sampling Ring Zobrazit vzorkovací kroužek
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu
Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -2128,6 +2128,7 @@
Grid: Raster:
Highlight Clipping Warning (O) Lichter-Abschneidewarnung (O)
Lasso around an area, then drag the selection Lasso um einen Bereich ziehen, dann die Auswahl verschieben
Now drag the selection onto another area Jetzt die Auswahl auf einen anderen Bereich ziehen
Patch: Korrektur:
Shadow Clipping Warning (U) Tiefen-Abschneidewarnung (U)
Show Lab Color Readouts Lab-Farbwerte anzeigen
@@ -2288,6 +2289,19 @@ fontWeight Italic Kursiv
Reset View Ansicht zurücksetzen
Drag around the centre to rotate the view · Shift constrains to 15° Ansicht drehen: im Bogen ziehen · Umschalt: 15°-Schritte
# Eyedropper Sample Size and Sample (#1649).
Point Sample 1 Pixel
3 by 3 Average 3 x 3 Pixel Durchschnitt
5 by 5 Average 5 x 5 Pixel Durchschnitt
11 by 11 Average 11 x 11 Pixel Durchschnitt
31 by 31 Average 31 x 31 Pixel Durchschnitt
51 by 51 Average 51 x 51 Pixel Durchschnitt
101 by 101 Average 101 x 101 Pixel Durchschnitt
Sample Size: Aufnahmebereich:
All Layers No Adjustments Alle Ebenen ohne Einstellungen
Current & Below No Adjustments Aktuelle und darunter ohne Einstellungen
Show Sampling Ring Aufnahmering anzeigen
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe
Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -1596,6 +1596,7 @@
Click to apply, ⌥-click to clear overrides, double-click for Style Options Κλικ για εφαρμογή, ⌥-κλικ για εκκαθάριση παρακάμψεων, διπλό κλικ για επιλογές στυλ
Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Κλικ: γωνία · Σύρσιμο: ομαλή καμπύλη · Κλικ στο πρώτο σημείο: κλείσιμο · ↩ ολοκλήρωση · Esc ακύρωση
Lasso around an area, then drag the selection Σχεδιάστε επιλογή με το Lasso γύρω από μια περιοχή και σύρετέ την
Now drag the selection onto another area Τώρα σύρετε την επιλογή σε άλλη περιοχή
Rulers (⌘R) Χάρακες (⌘R)
Show Guides (⌘;) Εμφάνιση οδηγών (⌘;)
Grid (⌘') Πλέγμα (⌘')
@@ -3362,6 +3363,19 @@ fontWeight Thin Πολύ λεπτά
Preset Name Όνομα προεπιλογής
No saved presets yet. Δεν υπάρχουν ακόμη αποθηκευμένες προεπιλογές.
# Eyedropper Sample Size and Sample (#1649).
Point Sample Σημειακό δείγμα
3 by 3 Average Μέσος όρος 3 × 3
5 by 5 Average Μέσος όρος 5 × 5
11 by 11 Average Μέσος όρος 11 × 11
31 by 31 Average Μέσος όρος 31 × 31
51 by 51 Average Μέσος όρος 51 × 51
101 by 101 Average Μέσος όρος 101 × 101
Sample Size: Μέγεθος δείγματος:
All Layers No Adjustments Όλα τα επίπεδα χωρίς προσαρμογές
Current & Below No Adjustments Τρέχον και υποκείμενα χωρίς προσαρμογές
Show Sampling Ring Εμφάνιση δακτυλίου δειγματοληψίας
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα
Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web
Can't render this file because it contains an unexpected character in line 960 and column 18.
+14
View File
@@ -2167,6 +2167,7 @@ cameraRaw Amount Cantidad
Patch Tool Herramienta Parche
Patch: Parche:
Lasso around an area, then drag the selection Rodee un área con el lazo y arrastre la selección
Now drag the selection onto another area Ahora arrastre la selección a otra área
Edit Type Editar texto
@@ -2318,6 +2319,19 @@ fontWeight Italic Cursiva
Reset View Restablecer vista
Drag around the centre to rotate the view · Shift constrains to 15° Arrastre alrededor del centro para rotar la vista · Mayús fija a 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Muestra puntual
3 by 3 Average Promedio de 3 × 3
5 by 5 Average Promedio de 5 × 5
11 by 11 Average Promedio de 11 × 11
31 by 31 Average Promedio de 31 × 31
51 by 51 Average Promedio de 51 × 51
101 by 101 Average Promedio de 101 × 101
Sample Size: Tamaño de muestra:
All Layers No Adjustments Todas las capas sin ajustes
Current & Below No Adjustments Actual e inferiores sin ajustes
Show Sampling Ring Mostrar anillo de muestreo
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano
Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano
Can't render this file because it contains an unexpected character in line 4 and column 47.
+14
View File
@@ -2518,6 +2518,7 @@ cameraRaw Blending Fusion
Patch Tool Outil Pièce
Patch: Pièce :
Lasso around an area, then drag the selection Entourez une zone au lasso, puis faites glisser la sélection
Now drag the selection onto another area Faites maintenant glisser la sélection vers une autre zone
# Warp grid presets (#388)
3 x 3 3 × 3
@@ -2672,6 +2673,19 @@ fontWeight Italic Italique
Reset View Réinitialiser la vue
Drag around the centre to rotate the view · Shift constrains to 15° Faites glisser autour du centre pour tourner la vue · Maj contraint à 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Échantillon ponctuel
3 by 3 Average Moyenne 3 × 3
5 by 5 Average Moyenne 5 × 5
11 by 11 Average Moyenne 11 × 11
31 by 31 Average Moyenne 31 × 31
51 by 51 Average Moyenne 51 × 51
101 by 101 Average Moyenne 101 × 101
Sample Size: Taille de l'échantillon :
All Layers No Adjustments Tous les calques sans ajustements
Current & Below No Adjustments Actif et inférieurs sans ajustements
Show Sampling Ring Afficher l'anneau d'échantillonnage
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche
Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -2492,6 +2492,7 @@ cameraRaw Blending Pembauran
Patch Tool Alat Patch
Patch: Patch:
Lasso around an area, then drag the selection Lingkari area dengan lasso, lalu seret seleksinya
Now drag the selection onto another area Sekarang seret seleksi ke area lain
Rendering Mode Mode Rendering
Automatic (recommended) Otomatis (disarankan)
CPU / Compatibility CPU / Kompatibilitas
@@ -2659,6 +2660,19 @@ fontWeight Italic Miring
Reset View Atur Ulang Tampilan
Drag around the centre to rotate the view · Shift constrains to 15° Seret di sekitar pusat untuk memutar tampilan · Shift membatasi ke 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Sampel Titik
3 by 3 Average Rata-rata 3 × 3
5 by 5 Average Rata-rata 5 × 5
11 by 11 Average Rata-rata 11 × 11
31 by 31 Average Rata-rata 31 × 31
51 by 51 Average Rata-rata 51 × 51
101 by 101 Average Rata-rata 101 × 101
Sample Size: Ukuran Sampel:
All Layers No Adjustments Semua Layer Tanpa Penyesuaian
Current & Below No Adjustments Saat Ini & Di Bawahnya Tanpa Penyesuaian
Show Sampling Ring Tampilkan Cincin Sampel
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat
Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat
Can't render this file because it contains an unexpected character in line 4 and column 47.
+14
View File
@@ -2173,6 +2173,7 @@ cameraRaw Amount Quantità
Patch Tool Strumento Toppa
Patch: Toppa:
Lasso around an area, then drag the selection Seleziona un'area con lazo, poi trascina la selezione
Now drag the selection onto another area Ora trascina la selezione su un'altra area
Edit Type Modifica testo
# Strings added on main after this catalog was written (2026-10).
@@ -2319,6 +2320,19 @@ fontWeight Italic Corsivo
Reset View Reimposta vista
Drag around the centre to rotate the view · Shift constrains to 15° Trascina intorno al centro per ruotare la vista · Maiusc vincola a 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Campione puntiforme
3 by 3 Average Media 3 × 3
5 by 5 Average Media 5 × 5
11 by 11 Average Media 11 × 11
31 by 31 Average Media 31 × 31
51 by 51 Average Media 51 × 51
101 by 101 Average Media 101 × 101
Sample Size: Dimensione campione:
All Layers No Adjustments Tutti i livelli senza regolazioni
Current & Below No Adjustments Corrente e inferiori senza regolazioni
Show Sampling Ring Mostra anello di campionamento
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino
Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2173
2174
2175
2176
2177
2178
2179
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
+14
View File
@@ -2077,6 +2077,7 @@ cameraRaw Color カラー
Patch Tool パッチツール
Patch: パッチ:
Lasso around an area, then drag the selection 投げ縄で範囲を囲み、選択範囲をドラッグします
Now drag the selection onto another area 選択範囲を別の場所へドラッグします
Edit Type 文字を編集
@@ -2232,6 +2233,19 @@ fontWeight Italic 斜体
Reset View 表示をリセット
Drag around the centre to rotate the view · Shift constrains to 15° 中心の周りをドラッグして表示を回転 · Shift で 15° 単位
# Eyedropper Sample Size and Sample (#1649).
Point Sample ポイントサンプル
3 by 3 Average 3 × 3 平均
5 by 5 Average 5 × 5 平均
11 by 11 Average 11 × 11 平均
31 by 31 Average 31 × 31 平均
51 by 51 Average 51 × 51 平均
101 by 101 Average 101 × 101 平均
Sample Size: サンプルサイズ:
All Layers No Adjustments すべてのレイヤー(色調補正なし)
Current & Below No Adjustments 現在のレイヤーとその下(色調補正なし)
Show Sampling Ring サンプリングリングを表示
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 印刷の色域外:クリックすると最も近い印刷可能な色になります
Not a web colour: click for the nearest web colour Web カラーではありません:クリックすると最も近い Web カラーになります
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -2045,6 +2045,7 @@ cameraRaw Color 색상
Patch Tool 패치 도구
Patch: 패치:
Lasso around an area, then drag the selection 올가미로 영역을 둘러싼 다음 선택 영역을 드래그하세요
Now drag the selection onto another area 이제 선택 영역을 다른 영역으로 드래그하세요
# Warp grid presets merged on main in #388.
3 x 3 3 × 3
@@ -2828,6 +2829,19 @@ fontWeight Italic 기울임
Reset View 보기 재설정
Drag around the centre to rotate the view · Shift constrains to 15° 중심 주위를 드래그해 보기를 회전합니다 · Shift는 15°로 제한
# Eyedropper Sample Size and Sample (#1649).
Point Sample 포인트 샘플
3 by 3 Average 3 × 3 평균
5 by 5 Average 5 × 5 평균
11 by 11 Average 11 × 11 평균
31 by 31 Average 31 × 31 평균
51 by 51 Average 51 × 51 평균
101 by 101 Average 101 × 101 평균
Sample Size: 샘플 크기:
All Layers No Adjustments 모든 레이어(조정 제외)
Current & Below No Adjustments 현재 및 이하(조정 제외)
Show Sampling Ring 샘플링 링 표시
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다
Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2045
2046 # Warp grid presets merged on main in #388.
2047 # Warp grid presets merged on main in #388.
2048
2049
2050
2051
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
+14
View File
@@ -2553,6 +2553,7 @@ cameraRaw Roughness Chropowatość
Patch Tool Narzędzie Łatka
Patch: Łatka:
Lasso around an area, then drag the selection Zakreśl obszar lassem, a następnie przeciągnij zaznaczenie
Now drag the selection onto another area Teraz przeciągnij zaznaczenie na inny obszar
Edit Type Edytuj tekst
Make Default Ustaw jako domyślne
Reset to Default Przywróć domyślne
@@ -3273,6 +3274,19 @@ cameraRaw Roughness Chropowatość
Reset View Resetuj widok
Drag around the centre to rotate the view · Shift constrains to 15° Przeciągnij wokół środka, aby obrócić widok · Shift ogranicza do 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Próbka punktowa
3 by 3 Average Uśrednianie 3 × 3
5 by 5 Average Uśrednianie 5 × 5
11 by 11 Average Uśrednianie 11 × 11
31 by 31 Average Uśrednianie 31 × 31
51 by 51 Average Uśrednianie 51 × 51
101 by 101 Average Uśrednianie 101 × 101
Sample Size: Wielkość próbki:
All Layers No Adjustments Wszystkie warstwy bez korekt
Current & Below No Adjustments Bieżąca i poniżej bez korekt
Show Sampling Ring Pokaż pierścień próbkowania
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku
Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -2512,6 +2512,7 @@ cameraRaw Blending Mesclagem
Patch Tool Ferramenta Correção
Patch: Correção:
Lasso around an area, then drag the selection Contorne uma área com o laço e arraste a seleção
Now drag the selection onto another area Agora arraste a seleção para outra área
3 x 3 3 × 3
4 x 4 4 × 4
5 x 5 5 × 5
@@ -2679,6 +2680,19 @@ fontWeight Italic Itálico
Reset View Redefinir visualização
Drag around the centre to rotate the view · Shift constrains to 15° Arraste ao redor do centro para girar a visualização · Shift restringe a 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Amostra pontual
3 by 3 Average Média de 3 × 3
5 by 5 Average Média de 5 × 5
11 by 11 Average Média de 11 × 11
31 by 31 Average Média de 31 × 31
51 by 51 Average Média de 51 × 51
101 by 101 Average Média de 101 × 101
Sample Size: Tamanho da amostra:
All Layers No Adjustments Todas as camadas sem ajustes
Current & Below No Adjustments Atual e abaixo sem ajustes
Show Sampling Ring Mostrar anel de amostragem
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima
Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -2370,6 +2370,7 @@ cameraRaw Roughness Неровность
Patch Tool Заплатка
Patch: Заплатка:
Lasso around an area, then drag the selection Обведите область лассо, затем перетащите выделение
Now drag the selection onto another area Теперь перетащите выделение на другую область
Edit Type Редактировать текст
@@ -2520,6 +2521,20 @@ fontWeight Italic Курсив
Reset View Сбросить вид
Drag around the centre to rotate the view · Shift constrains to 15° Перетащите вокруг центра, чтобы повернуть вид · Shift фиксирует шаг 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample Точечная выборка
3 by 3 Average Среднее 3 × 3
5 by 5 Average Среднее 5 × 5
11 by 11 Average Среднее 11 × 11
31 by 31 Average Среднее 31 × 31
51 by 51 Average Среднее 51 × 51
101 by 101 Average Среднее 101 × 101
Sample Size: Размер выборки:
All Layers No Adjustments Все слои без коррекции
Current & Below No Adjustments Текущий и ниже без коррекции
Show Sampling Ring Показать кольцо выборки
Click to sample the foreground colour · {key}-click for background Клик — взять основной цвет · {key}-клик — фоновый цвет
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет
Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14
View File
@@ -1970,6 +1970,7 @@ cameraRaw Blending 混合
Patch Tool 修补工具
Patch: 修补:
Lasso around an area, then drag the selection 用套索圈出区域,然后拖动选区
Now drag the selection onto another area 现在将选区拖动到另一个区域
# Current shell labels and shortcut templates. Keep {key} platform-dependent.
Add a mask (from the selection; {key} inverts) 添加蒙版 (基于选区;{key} 反相)
Add to selection ({key}) 添加到选区 ({key})
@@ -2257,6 +2258,19 @@ fontWeight Italic 斜体
Reset View 重置视图
Drag around the centre to rotate the view · Shift constrains to 15° 绕中心拖动以旋转视图 · Shift 限制为 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample 采样点
3 by 3 Average 3 × 3 平均
5 by 5 Average 5 × 5 平均
11 by 11 Average 11 × 11 平均
31 by 31 Average 31 × 31 平均
51 by 51 Average 51 × 51 平均
101 by 101 Average 101 × 101 平均
Sample Size: 采样大小:
All Layers No Adjustments 所有图层(不含调整)
Current & Below No Adjustments 当前及下方图层(不含调整)
Show Sampling Ring 显示采样环
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色
Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
1970
1971
1972 # Current shell labels and shortcut templates. Keep {key} platform-dependent.
1973 # Current shell labels and shortcut templates. Keep {key} platform-dependent.
1974
1975
1976
2258 # Color Picker warnings
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
+14
View File
@@ -2072,6 +2072,7 @@ cameraRaw Blending 混合
Patch Tool 修補工具
Patch: 修補:
Lasso around an area, then drag the selection 用套索圈出區域,然後拖曳選取範圍
Now drag the selection onto another area 現在將選取範圍拖曳到另一個區域
Edit Type 編輯文字
@@ -2226,6 +2227,19 @@ fontWeight Italic 斜體
Reset View 重設檢視
Drag around the centre to rotate the view · Shift constrains to 15° 繞中心拖曳以旋轉檢視 · Shift 限制為 15°
# Eyedropper Sample Size and Sample (#1649).
Point Sample 取樣點
3 by 3 Average 3 × 3 平均
5 by 5 Average 5 × 5 平均
11 by 11 Average 11 × 11 平均
31 by 31 Average 31 × 31 平均
51 by 51 Average 51 × 51 平均
101 by 101 Average 101 × 101 平均
Sample Size: 取樣大小:
All Layers No Adjustments 所有圖層(不含調整)
Current & Below No Adjustments 目前及以下(不含調整)
Show Sampling Ring 顯示取樣環
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色
Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+68 -6
View File
@@ -331,25 +331,87 @@ fn doc_at(h: &Harness<'static, PhotocraftApp>, p: Pos2) -> [f32; 2] {
[v.center[0] + (p.x - c.x) / v.zoom, v.center[1] + (p.y - c.y) / v.zoom]
}
/// The whole window (status bar and Navigator included) on a 400 × 300 document.
fn app_window() -> Harness<'static, PhotocraftApp> {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 400, "height": 300})).unwrap();
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(s, crate::Services::default())
});
h.run_steps(8);
h
}
/// Photoshop 25.4: ⌥ + wheel goes on past 3200 % to exactly 12800 %, and further notches there
/// change nothing. The status bar's zoom field used to stop at 3200 %, clamping the zoom back
/// every frame: past it, each notch zoomed around the pointer and the field pulled the zoom
/// back, so the image slid towards the pointer.
#[test]
fn alt_scroll_zooms_gently_around_the_pointer() {
fn the_wheel_reaches_12800_percent_and_stops_dead_there() {
let mut h = app_window();
{
let v = &mut h.state_mut().ui.views[0];
(v.zoom, v.center, v.fit_pending) = (40.0, [200.0, 150.0], false);
}
h.run_steps(4);
assert_eq!(zoom(&h), 40.0, "4000 % holds: no zoom control clamps it back");
let r = h.state().last_canvas_rect;
let p = pos2(r.center().x - 150.0, r.center().y - 90.0);
// The document point under `p`, through the canvas inside the rulers.
let doc_at = |h: &Harness<'static, PhotocraftApp>, p: Pos2| {
let (app, v) = (h.state(), &h.state().ui.views[0]);
let c = crate::rulers::content_rect(app, app.last_canvas_rect).center();
[v.center[0] + (p.x - c.x) / v.zoom, v.center[1] + (p.y - c.y) / v.zoom]
};
h.hover_at(p);
h.run_steps(2);
let d0 = doc_at(&h, p);
for _ in 0..14 {
wheel(&h, 1.0, Modifiers::ALT);
h.run_steps(30);
}
assert_eq!(zoom(&h), crate::zoom_levels::MAX, "a notch past the limit lands on it exactly");
let d1 = doc_at(&h, p);
assert!((d1[0] - d0[0]).abs() < 0.02 && (d1[1] - d0[1]).abs() < 0.02, "zoomed around the pointer: {d0:?} -> {d1:?}");
let c = h.state().ui.views[0].center;
for _ in 0..4 {
wheel(&h, 1.0, Modifiers::ALT);
h.run_steps(30);
}
assert_eq!(zoom(&h), crate::zoom_levels::MAX);
assert_eq!(h.state().ui.views[0].center, c, "at the limit the image doesn't move");
// One notch back: 12800 / 1.1, still around the pointer.
wheel(&h, -1.0, Modifiers::ALT);
h.run_steps(30);
assert!((zoom(&h) - crate::zoom_levels::MAX / 1.1).abs() < 1e-2, "{}", zoom(&h));
let d2 = doc_at(&h, p);
assert!((d2[0] - d0[0]).abs() < 0.02 && (d2[1] - d0[1]).abs() < 0.02, "{d0:?} -> {d2:?}");
}
#[test]
fn alt_scroll_zooms_in_steps_around_the_pointer() {
let mut h = harness();
let r = h.state().last_canvas_rect;
let p = pos2(r.center().x + 120.0, r.center().y - 70.0);
h.hover_at(p);
h.run_steps(2);
let (z0, d0) = (zoom(&h), doc_at(&h, p));
// One notch with ⌥ held: +5%, the point under the pointer stays put. The modifiers are
// released right after the event, while egui still smooths the notch over later frames.
// One notch with ⌥ held: ×1.1 (Photoshop), the point under the pointer stays put. The modifiers are
// released right after the event, while egui still smooths the notch over later frames, which
// must neither ease the zoom nor turn into a pan (#1490).
wheel(&h, 1.0, Modifiers::ALT);
h.run_steps(2);
let at_once = zoom(&h);
h.run_steps(40);
let (z1, d1) = (zoom(&h), doc_at(&h, p));
assert!((z1 / z0 - 1.05).abs() < 1e-3, "one ⌥ notch is 5%: {z0} -> {z1}");
assert!((z1 / z0 - 1.1).abs() < 1e-3, "one ⌥ notch is ×1.1: {z0} -> {z1}");
assert_eq!(at_once, z1, "the notch is applied the frame it arrives, with no easing");
assert!((d1[0] - d0[0]).abs() < 0.05 && (d1[1] - d0[1]).abs() < 0.05, "centred on the pointer: {d0:?} -> {d1:?}");
// Three notches back out.
wheel(&h, -3.0, Modifiers::ALT);
h.run_steps(40);
assert!((zoom(&h) / z1 - 1.05f32.powi(-3)).abs() < 1e-3, "{z1} -> {}", zoom(&h));
assert!((zoom(&h) / z1 - 1.1f32.powi(-3)).abs() < 1e-3, "{z1} -> {}", zoom(&h));
// A plain notch still pans, and does not zoom.
let (z2, c2) = (zoom(&h), h.state().ui.views[0].center);
@@ -385,7 +447,7 @@ fn alt_scroll_zooms_while_a_temporary_tool_is_held() {
let z0 = zoom(&h);
wheel(&h, 2.0, Modifiers::ALT);
h.run_steps(40);
assert!((zoom(&h) / z0 - 1.05f32.powi(2)).abs() < 1e-3, "{z0} -> {}", zoom(&h));
assert!((zoom(&h) / z0 - 1.1f32.powi(2)).abs() < 1e-3, "{z0} -> {}", zoom(&h));
h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().ui.tool, crate::state::Tool::Brush);
+7 -2
View File
@@ -55,6 +55,7 @@ pub mod dock;
pub mod enable_rules;
pub mod eraser_ui;
pub mod export_dialog;
pub mod eyedropper_ui;
pub mod file_dialog;
pub mod file_open;
pub mod file_ui;
@@ -147,6 +148,7 @@ pub mod wide_angle_ui;
pub mod widgets;
pub mod work_area;
pub mod workspace_ui;
pub mod zoom_levels;
pub mod zoom_tool;
use std::collections::HashMap;
@@ -313,6 +315,9 @@ pub struct Services {
/// (document, compute ms, histograms)).
pub(crate) type HistJob = (DocId, u64, std::sync::mpsc::Receiver<(DocId, f64, std::sync::Arc<tone::Histograms>)>);
/// The Info panel's cached sample: pixel x, y, document revision and Eyedropper Sample Size.
type InfoSampleKey = (i32, i32, u64, u32);
pub struct PhotocraftApp {
pub session: Session,
pub ui: UiState,
@@ -449,8 +454,8 @@ pub struct PhotocraftApp {
/// Pointer position over the canvas (document px), for the Info panel and status bar.
pub(crate) hover_doc: Option<[f64; 2]>,
pub(crate) clone_preview: Option<crate::canvas::ClonePreviewCache>,
/// Info panel sample cache: ((x, y, revision), composite RGBA).
info_sample: Option<((i32, i32, u64), [f32; 4])>,
/// Info panel sample cache: ((x, y, revision, Sample Size), composite RGBA).
info_sample: Option<(InfoSampleKey, [f32; 4])>,
/// Guide being dragged (from a ruler or with the Move tool).
pub(crate) guide_drag: Option<rulers::GuideDrag>,
/// Crop tool gesture in progress (see `crop_ui`).
+3
View File
@@ -715,6 +715,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
&& let Some(hp) = resp.hover_pos()
{
let before = to_doc(d, area, hp);
// Liquify's own preview zoom, not the canvas (`zoom_levels` doesn't apply): the
// preview is a proxy of at most PROXY_SIDE px, so past 3200 % it would only magnify
// proxy pixels further.
d.zoom = (d.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0);
let after = to_doc(d, area, hp);
d.center = [d.center[0] + before[0] - after[0], d.center[1] + before[1] - after[1]];
+50 -3
View File
@@ -633,12 +633,23 @@ impl NativeMenu {
/// Before egui sees this frame's input: key equivalents become the key presses they were,
/// and clicks wait for [`NativeMenu::run`].
///
/// AppKit takes only the key-down: winit still delivers the key-up (it forwards key-ups while
/// ⌘ is held). So a key equivalent adds just its press, ahead of its key-up when that is
/// already queued. A second, made-up release made ⌘V paste three times (#1638): a ⌘V release
/// without a press is how an image paste reaches us (see [`crate::shortcuts::clipboard_keys`]).
pub fn raw_input(&mut self, raw: &mut egui::RawInput) {
for e in self.backend.drain() {
match e {
Event::Key(chord) => {
raw.events.extend(chord.key_event(true));
raw.events.extend(chord.key_event(false));
if let Some(press @ egui::Event::Key { key: pressed_key, .. }) = chord.key_event(true) {
let at = raw
.events
.iter()
.position(|e| matches!(e, egui::Event::Key { key, pressed: false, .. } if *key == pressed_key))
.unwrap_or(raw.events.len());
raw.events.insert(at, press);
}
}
Event::Click(id) => self.clicks.push(id),
}
@@ -934,7 +945,7 @@ mod tests {
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
assert_eq!(raw.events.len(), 2, "a press and a release");
assert_eq!(raw.events.len(), 1, "the press: winit delivers the release");
frame(&ctx, raw, |ui| {
run(&mut app, ui.ctx());
crate::shortcuts::handle(&mut app, ui.ctx());
@@ -947,6 +958,42 @@ mod tests {
assert_eq!(synced.borrow().len(), 1);
}
/// #1638: ⌘V through the Mac menu pasted three layers. AppKit takes the key-down for Edit ›
/// Paste's key equivalent, but winit still delivers the key-up (it forwards key-ups while ⌘ is
/// held). The menu's press plus that real release must paste once, whether the release
/// arrives in a later frame or already sits in the same input as the menu event.
#[test]
fn a_paste_key_equivalent_pastes_once() {
let release = || egui::Event::Key { key: Key::V, physical_key: Some(Key::V), pressed: false, repeat: false, modifiers: Modifiers::COMMAND };
for same_frame in [false, true] {
let mut app = app(true);
let ctx = egui::Context::default();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 4})).unwrap();
app.run("edit.copy", json!({})).unwrap();
let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layer_count();
let before = layers(&app);
let paste = Chord::parse("Cmd+V").unwrap();
app.services.native_menu = Some(NativeMenu::new(Box::new(Fake { synced: Default::default(), events: vec![Event::Key(paste)] })));
let step = |app: &mut PhotocraftApp, events: Vec<egui::Event>| {
let mut raw = egui::RawInput { events, ..Default::default() };
eframe::App::raw_input_hook(app, &ctx, &mut raw);
frame(&ctx, raw, |ui| {
run(app, ui.ctx());
crate::shortcuts::handle(app, ui.ctx());
});
};
if same_frame {
// A quick tap: the real key-up is already queued when the menu event is drained.
step(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), release()]);
} else {
step(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::COMMAND)]);
step(&mut app, vec![release()]);
}
step(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::NONE)]);
assert_eq!(layers(&app), before + 1, "one ⌘V pastes one layer (key-up in the same frame: {same_frame})");
}
}
#[test]
fn clicks_run_the_command_and_resync() {
let synced = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
+139 -34
View File
@@ -935,13 +935,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:"));
hint(ui, tl!("Drag to move the active layer"));
}
Tool::Eyedropper => hint(
ui,
&crate::i18n::fmt(
tl!("Click to sample the foreground colour · {key}-click for background"),
&[("key", &crate::shortcuts::pretty("Alt"))],
),
),
Tool::Eyedropper => crate::eyedropper_ui::options(app, ui),
Tool::Zoom => {
widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom"));
hint(
@@ -1101,8 +1095,8 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let (w, h, mode, bits, layers) =
(st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count());
let mut pct = app.ui.views[i].zoom * 100.0;
if widgets::value_field(ui, &mut pct, 1.0..=3200.0, "%", 78.0).changed() {
app.ui.views[i].zoom = pct / 100.0;
if widgets::value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 78.0).changed() {
app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size);
app.ui.views[i].fit_pending = false;
}
widgets::vline(ui, 16.0);
@@ -1262,15 +1256,18 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.hover_doc
.map(|p| (p[0].floor() as i32, p[1].floor() as i32))
.filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32);
// The readout averages the Eyedropper's Sample Size, as in Photoshop (#1649).
let sample_size = app.ui.tool_options.eyedropper_size;
let rgba = pos.and_then(|(x, y)| {
if let Some(((cx, cy, cr), v)) = app.info_sample
&& (cx, cy, cr) == (x, y, rev)
if let Some(((cx, cy, cr, cs), v)) = app.info_sample
&& (cx, cy, cr, cs) == (x, y, rev, sample_size)
{
return Some(v);
}
let v: Vec<f32> = serde_json::from_value(app.session.execute("document.pixel", json!({"x": x, "y": y})).ok()?).ok()?;
let v = [v[0], v[1], v[2], v[3]];
app.info_sample = Some(((x, y, rev), v));
let params = json!({"x": f64::from(x) + 0.5, "y": f64::from(y) + 0.5, "size": sample_size});
let v: Vec<f32> = serde_json::from_value(app.session.execute("document.sampleColor", params).ok()?).ok()?;
let v = [*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?];
app.info_sample = Some(((x, y, rev, sample_size), v));
Some(v)
});
let mono = theme::mono(11.5);
@@ -1361,9 +1358,11 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
});
});
let mut lz = v.zoom.max(0.01).log2();
if widgets::slider(ui, &mut lz, -6.64..=5.0, None).changed() {
app.ui.views[idx].zoom = 2f32.powf(lz);
// The whole zoom range, and always the current zoom: a narrower slider would pull it back.
let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2());
let mut lz = v.zoom.log2();
if lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed() {
app.ui.views[idx].zoom = crate::zoom_levels::clamp(2f32.powf(lz), v.doc_size);
app.ui.views[idx].fit_pending = false;
}
}
@@ -1434,20 +1433,22 @@ fn blend_options(groups: bool) -> Vec<(BlendMode, &'static str)> {
std::iter::once(BlendMode::PassThrough).filter(|_| groups).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect()
}
/// Scroll the Layers panel while holding a layer drag over its top/bottom edge.
/// Scroll the Layers panel while holding a layer drag over its top/bottom edge or past them.
///
/// Returns the *content* displacement in points for this frame, so positive moves the
/// list downward (reveals rows above) and negative upward (reveals rows below).
/// The speed ramps with proximity to the edge and uses elapsed time instead of
/// assuming a particular refresh rate.
/// The speed ramps with proximity to the edge and clamps to the maximum speed when
/// dragged outside, using elapsed time instead of assuming a particular refresh rate.
fn layer_drag_edge_scroll(pointer: Option<Pos2>, viewport: Rect, dragging: bool, dt: f32) -> f32 {
if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 {
if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 || dt.is_nan() || dt <= 0.0 {
return 0.0;
}
let Some(pointer) = pointer.filter(|p| viewport.contains(*p)) else { return 0.0 };
let Some(pointer) = pointer.filter(|p| p.x.is_finite() && p.y.is_finite() && p.x >= viewport.left() && p.x <= viewport.right()) else {
return 0.0;
};
let edge = 32.0_f32.min(viewport.height() * 0.25);
let top = (edge - (pointer.y - viewport.top())).max(0.0) / edge;
let bottom = (edge - (viewport.bottom() - pointer.y)).max(0.0) / edge;
let top = ((edge - (pointer.y - viewport.top())) / edge).clamp(0.0, 1.0);
let bottom = ((edge - (viewport.bottom() - pointer.y)) / edge).clamp(0.0, 1.0);
let direction = top - bottom;
if direction == 0.0 {
return 0.0;
@@ -1586,9 +1587,14 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
.min_scrolled_height(if fill { rows_h } else { 0.0 })
.auto_shrink([false, !fill])
.show(ui, |ui| {
// The drag key is set only by actual layer-row drags, not clicks or
// The drag keys are set only by actual layer-row and fx drags, not clicks or
// ordinary scrolling. The ScrollArea applies this to its own content.
let dragging = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))).is_some() && ctx.input(|i| i.pointer.primary_down());
// An fx drag that never saw its release (the panel was hidden) must not outlive the button.
if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) {
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
}
let held = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag")).is_some() || d.get_temp::<FxDrag>(fx_drag_key()).is_some());
let dragging = held && ctx.input(|i| i.pointer.primary_down());
let pointer = ctx.input(|i| i.pointer.interact_pos());
let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt));
if delta != 0.0 {
@@ -1644,9 +1650,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let in_selection = selection.iter().any(|s| s.0 == id);
(id, in_selection)
});
fx_drag_feedback(&ctx, &doc);
// End any layer drag after every row has had a chance to accept the drop.
if ctx.input(|i| i.pointer.any_released()) {
ctx.data_mut(|d| d.remove::<u64>(egui::Id::new("layer-drag")));
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
}
widgets::panel_footer(ui, |ui| {
let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer"));
@@ -1886,6 +1894,7 @@ fn layer_row(
let row_h = if t.pro { 32.0 } else { 46.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions);
fx_drop(ctx, ui, l, rect, actions);
if resp.drag_started() {
crate::layer_transfer::begin_from_panel(app, ctx, l.id);
}
@@ -1977,6 +1986,13 @@ fn layer_row(
// Right-hand indicators first; the name gets what is left and ends in "…" (#144).
let fx_open = app.session.active().is_none_or(|d| !d.fx_collapsed.contains(&l.id));
let (name_right, indicators, fx_toggled) = crate::layer_row_ui::indicators(ui, &painter, rect, x, l, fx_open, actions);
// Dragging the fx badge drags all the layer's effects, not the layer (Photoshop). Like the eye,
// it takes the drag from the row; clicks still reach the row.
if let Some(&(_, badge)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Fx)
&& ui.interact(badge, ui.id().with(("fx-badge", l.id.0)), Sense::drag()).drag_started()
{
start_fx_drag(ctx, l.id, None);
}
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
@@ -2825,6 +2841,60 @@ fn layer_drag_and_drop(
}
}
/// An fx row being dragged: the layer it belongs to and the effect index (`None` = the "Effects"
/// row, all of them).
type FxDrag = (u64, Option<usize>);
fn fx_drag_key() -> egui::Id {
egui::Id::new("fx-drag")
}
/// Start dragging the effects of `layer`: one (`effect`) or all of them.
fn start_fx_drag(ctx: &egui::Context, layer: LayerId, effect: Option<usize>) {
ctx.data_mut(|d| d.insert_temp::<FxDrag>(fx_drag_key(), (layer.0, effect)));
}
/// While effects are dragged: their name by the pointer, and the copy cursor while ⌥ is held.
fn fx_drag_feedback(ctx: &egui::Context, doc: &photocraft_doc::Document) {
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
let label = match effect {
Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map_or("", |e| e.label()),
None => "Effects",
};
crate::layer_transfer::ghost(ctx, p, label);
if ctx.input(|i| i.modifiers.alt) {
ctx.set_cursor_icon(egui::CursorIcon::Copy);
}
}
/// The command for dropping effects of layer `from` on `target`: they move there, or with ⌥ held
/// on release are copied (Photoshop). `effect` is one effect row, or all effects when `None`.
fn fx_drop_action(from: u64, effect: Option<usize>, target: LayerId, copy: bool) -> (String, Value) {
let mut payload = json!({"from": from, "to": target.0, "copy": copy});
if let Some(o) = payload.as_object_mut()
&& let Some(i) = effect
{
o.insert("effect".into(), json!(i));
}
("layer.layerStyle.transferEffects".into(), payload)
}
/// A layer row accepts the fx row being dragged: outlined while the pointer is over it, dropped
/// on release.
fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) {
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
if from == l.id.0 || !rect.contains(p) {
return;
}
let t = Tokens::get(ctx);
ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
if ctx.input(|i| i.pointer.any_released()) {
actions.push(fx_drop_action(from, effect, l.id, ctx.input(|i| i.modifiers.alt)));
}
}
/// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). The eye
/// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622).
fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) {
@@ -2836,7 +2906,11 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi
rows.push((e.label().to_string(), e.enabled(), kind));
}
for (i, (name, on, kind)) in rows.into_iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
// Drag the row onto another layer to move its effects there, ⌥-drag to copy them.
if resp.drag_started() {
start_fx_drag(ui.ctx(), l.id, i.checked_sub(1));
}
if !ui.is_rect_visible(rect) {
continue;
}
@@ -3172,6 +3246,10 @@ mod history_transform_tests {
#[path = "layer_pct_slider_tests.rs"]
mod layer_pct_slider_tests;
#[cfg(test)]
#[path = "fx_drag_tests.rs"]
mod fx_drag_tests;
#[cfg(test)]
mod lock_tests {
use super::*;
@@ -3486,7 +3564,7 @@ mod layer_drag_edge_scroll_tests {
use super::*;
#[test]
fn scrolls_both_edges_with_distance_dependent_velocity() {
fn inside_edge_scrolls_proportional_to_proximity() {
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
let point = |y| Some(pos2(100.0, y));
let near_top = layer_drag_edge_scroll(point(33.0), viewport, true, 1.0 / 60.0);
@@ -3500,18 +3578,45 @@ mod layer_drag_edge_scroll_tests {
}
#[test]
fn scrolling_stops_outside_or_after_the_drag_finishes() {
fn outside_edges_scroll_in_drag_direction() {
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
let above = layer_drag_edge_scroll(Some(pos2(100.0, 20.0)), viewport, true, 1.0 / 60.0);
let below = layer_drag_edge_scroll(Some(pos2(100.0, 335.0)), viewport, true, 1.0 / 60.0);
assert!(above > 0.0, "dragging past the top continues scrolling upward");
assert!(below < 0.0, "dragging past the bottom continues scrolling downward");
}
#[test]
fn clamp_bounds_speed_outside_edges_and_across_frame_times() {
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
let at_top = layer_drag_edge_scroll(Some(pos2(100.0, 30.0)), viewport, true, 1.0 / 60.0);
let past_top = layer_drag_edge_scroll(Some(pos2(100.0, 15.0)), viewport, true, 1.0 / 60.0);
let far_past_top = layer_drag_edge_scroll(Some(pos2(100.0, -50.0)), viewport, true, 1.0 / 60.0);
assert_eq!(past_top, at_top, "speed past top edge is clamped to maximum edge speed");
assert_eq!(far_past_top, at_top, "speed far past top edge remains clamped");
let at_bottom = layer_drag_edge_scroll(Some(pos2(100.0, 330.0)), viewport, true, 1.0 / 60.0);
let past_bottom = layer_drag_edge_scroll(Some(pos2(100.0, 345.0)), viewport, true, 1.0 / 60.0);
let far_past_bottom = layer_drag_edge_scroll(Some(pos2(100.0, 500.0)), viewport, true, 1.0 / 60.0);
assert_eq!(past_bottom, at_bottom, "speed past bottom edge is clamped to maximum edge speed");
assert_eq!(far_past_bottom, at_bottom, "speed far past bottom edge remains clamped");
let active = Some(pos2(100.0, 325.0));
let step = layer_drag_edge_scroll(active, viewport, true, 1.0 / 60.0);
let twice = layer_drag_edge_scroll(active, viewport, true, 2.0 / 60.0);
assert!((twice - step * 2.0).abs() < 1e-4, "time-based scrolling scales across refresh rates");
assert!(layer_drag_edge_scroll(active, viewport, true, 0.5).abs() <= 30.0, "long frames have a bounded step");
}
#[test]
fn zero_when_no_drag_or_outside_horizontal_bounds() {
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
let active = Some(pos2(100.0, 325.0));
assert_eq!(layer_drag_edge_scroll(active, viewport, false, 1.0 / 60.0), 0.0);
assert_eq!(layer_drag_edge_scroll(None, viewport, true, 1.0 / 60.0), 0.0);
assert_eq!(layer_drag_edge_scroll(Some(pos2(9.0, 325.0)), viewport, true, 1.0 / 60.0), 0.0);
assert_eq!(layer_drag_edge_scroll(Some(pos2(100.0, 335.0)), viewport, true, 1.0 / 60.0), 0.0);
assert_eq!(layer_drag_edge_scroll(Some(pos2(311.0, 325.0)), viewport, true, 1.0 / 60.0), 0.0);
assert_eq!(layer_drag_edge_scroll(active, viewport, true, 0.0), 0.0);
let step = layer_drag_edge_scroll(active, viewport, true, 1.0 / 60.0);
let twice = layer_drag_edge_scroll(active, viewport, true, 2.0 / 60.0);
assert!((twice - step * 2.0).abs() < 1e-4, "time-based scrolling scales across refresh rates");
assert!(layer_drag_edge_scroll(active, viewport, true, 0.5).abs() <= 30.0, "long frames have a bounded step");
}
#[test]
+32
View File
@@ -154,10 +154,19 @@ pub fn patch_offset(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) ->
[clamp(dx, canvas.x0 - b.x0, canvas.x1 - b.x1), clamp(dy, canvas.y0 - b.y0, canvas.y1 - b.y1)]
}
/// Status-bar hint for the Patch and Content-Aware Move tools: the selection is the patch and it
/// has to be dragged somewhere. Without it a lasso, or a click inside the selection, changes
/// nothing on the canvas and the tool looks dead (#1715).
pub fn patch_hint(app: &mut PhotocraftApp) {
app.ui.status = tl!("Now drag the selection onto another area").into();
app.ui.status_error = false;
}
/// Patch Tool: the patch was dragged from `start` to `end`.
pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
let off = patch_offset(app, start, end);
if off == [0, 0] {
patch_hint(app);
return;
}
let preview = crate::patch_preview::take(app);
@@ -176,6 +185,7 @@ pub fn finish_patch(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
pub fn finish_content_aware_move(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
let off = patch_offset(app, start, end);
if off == [0, 0] {
patch_hint(app);
return;
}
let o = &app.ui.tool_options;
@@ -648,6 +658,28 @@ mod tests {
assert_eq!(h.state().ui.tool_options.red_eye_darken, 50.0);
}
#[test]
fn patch_tool_says_to_drag_the_selection_after_the_lasso_and_after_a_click() {
let hint = tl!("Now drag the selection onto another area");
let m = egui::Modifiers::NONE;
let mut app = app();
app.ui.tool = Tool::Patch;
app.ui.status.clear();
// No selection yet: the drag is a lasso, which outlines the patch and says what comes next.
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 10.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 40.0, y: 10.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 40.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 30.0 }, m);
assert!(app.session.active().is_some_and(|st| st.doc.selection.is_some()), "the lasso made a selection");
assert_eq!((app.ui.status.as_str(), app.ui.status_error), (hint, false));
// A press and release inside the selection moves nothing: it says so instead of staying silent.
app.ui.status.clear();
tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 20.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 20.0 }, m);
assert_eq!((app.ui.status.as_str(), app.ui.status_error), (hint, false));
assert!(app.session.active().is_some_and(|st| st.doc.selection.is_some()), "the selection stays");
}
#[test]
fn red_eye_is_in_the_j_flyout() {
let j = [Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye];
+23 -13
View File
@@ -71,20 +71,20 @@ impl Span {
}
}
/// Clamp one axis of the view centre with Overscroll off: the image can't leave its edges; an
/// image smaller than the view is centred.
/// Clamp one axis of the view centre with Overscroll off, as Photoshop 25.4 does: an image larger
/// than the view can't leave its edges, and one smaller than the view stays inside it, where
/// zooming put it (zooming out around the pointer doesn't re-centre it; Fit on Screen does).
pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
if !(zoom.is_finite() && zoom > 0.0 && len.is_finite() && len > 0.0 && size.is_finite() && size > 0.0) {
return center;
}
let half = size / zoom / 2.0;
if 2.0 * half >= len {
len / 2.0
} else if center.is_finite() {
center.clamp(half, len - half)
} else {
len / 2.0
if !center.is_finite() {
return len / 2.0;
}
let half = size / zoom / 2.0;
// Larger: the view stays within the image. Smaller: the image stays within the view.
let (a, b) = (half, len - half);
center.clamp(a.min(b), a.max(b))
}
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points. True
@@ -278,8 +278,12 @@ mod tests {
assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 1.0), 250.0);
assert_eq!(clamp_axis(4000.0, 1000.0, 500.0, 1.0), 750.0);
assert_eq!(clamp_axis(600.0, 1000.0, 500.0, 1.0), 600.0);
// Smaller than the view: centred.
assert_eq!(clamp_axis(-90.0, 1000.0, 500.0, 0.2), 500.0);
// Smaller than the view (2500 doc px of view for 1000 of image): it stays where it is
// while it is inside the view, and is pushed back in at the edges.
assert_eq!(clamp_axis(-90.0, 1000.0, 500.0, 0.2), -90.0);
assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 0.2), -250.0, "the image's right edge on the view's");
assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's");
assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred");
let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800], rotation: 0.0 };
assert!(clamp_view(&mut v, vec2(500.0, 400.0)));
assert_eq!(v.center, [250.0, 200.0]);
@@ -458,10 +462,16 @@ mod tests {
} else {
assert!((x - w / 2.0).abs() < 1e-3, "clamped to the left edge: {x}");
}
// Zoomed out below fit: centred.
// Zoomed out below fit (Photoshop): the image is not re-centred, it stays where it
// is while it fits in the view, and is pushed back in at the view's edge.
set_view(&mut h, 0.1, [-50.0, 9000.0]);
if !overscroll {
assert_eq!(view(&h).center, [1000.0, 750.0]);
let r = h.state().last_canvas_rect;
let (hw, hh) = (r.width() / 0.1 / 2.0, r.height() / 0.1 / 2.0);
let c = view(&h).center;
assert_eq!(c[0], -50.0, "inside the view: left alone");
assert!((c[1] - hh).abs() < 1e-2, "pushed back in: the image's top on the view's: {c:?}");
assert!(c[0] - hw <= 0.0 && c[0] + hw >= 2000.0 && c[1] - hh <= 0.0 && c[1] + hh >= 1500.0, "{c:?}");
}
}
}
+42 -9
View File
@@ -172,7 +172,8 @@ fn clipboard_press(e: &egui::Event, mods: Modifiers, is_windows: bool) -> Option
/// egui-winit turns ⌘C / ⌘X / ⌘V into `Event::Copy` / `Cut` / `Paste` and drops the key press, and
/// drops ⌘V entirely when the clipboard holds no text (an image). Outside text fields, give the
/// pixel commands their key presses back: Copy/Cut/Paste become ⌘C/⌘X/⌘V presses (with ⇧/⌥ as
/// held, so ⇧⌘C is Copy Merged), and a ⌘V release without a paste event becomes a ⌘V press.
/// held, so ⇧⌘C is Copy Merged), and a ⌘V release without a ⌘V press (a paste event, or the Mac
/// menu's key equivalent) becomes a ⌘V press.
///
/// Ctrl+Insert copies and Shift+Insert pastes: egui-winit maps them on Windows (a Shift+Insert
/// release without a paste event pastes the image, like ⌘V), and we map them here on Linux.
@@ -212,15 +213,28 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu
out.push(press(key, held));
continue;
}
if let egui::Event::Key { key, pressed: false, modifiers, .. } = &e
&& let Some(held) = match key {
Key::V if modifiers.command => Some(*modifiers),
Key::Insert if cfg!(target_os = "windows") && modifiers.shift && !modifiers.ctrl => Some(Modifiers::COMMAND),
_ => None,
match &e {
// A ⌘V press egui-winit didn't swallow (the Mac menu's key equivalent, Shift+Insert on
// Linux): its release must not paste a second time (#1638).
egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command => {
ctx.data_mut(|d| d.insert_temp(seen, true));
}
&& !ctx.data_mut(|d| d.remove_temp::<bool>(seen)).unwrap_or(false)
{
out.push(press(Key::V, held));
// A paste key's release ends that press, with or without ⌘ still held (else a stale
// mark would swallow the next image paste); without a press it is the paste itself.
egui::Event::Key { key: key @ (Key::V | Key::Insert), pressed: false, modifiers, .. } => {
let had_press = ctx.data_mut(|d| d.remove_temp::<bool>(seen)).unwrap_or(false);
let held = match key {
Key::V if modifiers.command => Some(*modifiers),
Key::Insert if cfg!(target_os = "windows") && modifiers.shift && !modifiers.ctrl => Some(Modifiers::COMMAND),
_ => None,
};
if let Some(held) = held
&& !had_press
{
out.push(press(Key::V, held));
}
}
_ => {}
}
out.push(e);
}
@@ -487,6 +501,25 @@ mod tests {
assert!(matches!(r.events.as_slice(), [_, egui::Event::Paste(_)]));
}
/// #1638: every ⌘V is one press, however its key-down and key-up arrive.
#[test]
fn each_paste_key_is_one_press() {
let ctx = egui::Context::default();
let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
let presses = |events: Vec<egui::Event>| {
let mut r = egui::RawInput { events, ..Default::default() };
clipboard_keys(&ctx, false, &mut r);
r.events.iter().filter(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, .. })).count()
};
// A ⌘V press that reached us (the Mac menu's key equivalent), then its release.
assert_eq!(presses(vec![key(Key::V, true, Modifiers::COMMAND)]), 1);
assert_eq!(presses(vec![key(Key::V, false, Modifiers::COMMAND)]), 0, "the release of a press doesn't paste again");
// A text paste whose V is released after ⌘: the next image paste still pastes.
assert_eq!(presses(vec![egui::Event::Paste("x".into())]), 1);
assert_eq!(presses(vec![egui::Event::ModifiersChanged(Modifiers::NONE), key(Key::V, false, Modifiers::NONE)]), 0);
assert_eq!(presses(vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), key(Key::V, false, Modifiers::COMMAND)]), 1, "image paste");
}
/// #530: egui-winit sends Cut for Shift+Delete on Windows; on the canvas it opens Fill.
#[test]
fn windows_shift_delete_fills_instead_of_cutting() {
+23
View File
@@ -530,6 +530,18 @@ pub struct ToolOptions {
pub red_eye_pupil_size: f32,
#[serde(default = "fifty")]
pub red_eye_darken: f32,
/// Eyedropper › Sample Size: 1 = Point Sample, else the side of the averaged square (3, 5,
/// 11, 31, 51, 101; `photocraft_engine::sample_cmds::SAMPLE_SIZES`). Shared, as in Photoshop,
/// with the ⌥-click eyedropper of the painting tools, the dialog eyedroppers and the Info panel.
#[serde(default = "one_u32")]
pub eyedropper_size: u32,
/// Eyedropper › Sample: current | currentAndBelow | all | allNoAdjustments |
/// currentAndBelowNoAdjustments (`document.sampleColor`'s `sampleLayer`).
#[serde(default = "default_eyedropper_sample")]
pub eyedropper_sample: String,
/// Eyedropper › Show Sampling Ring.
#[serde(default = "yes")]
pub eyedropper_ring: bool,
}
fn yes() -> bool {
@@ -557,6 +569,14 @@ fn default_brush_tool() -> Tool {
Tool::Brush
}
fn one_u32() -> u32 {
1
}
fn default_eyedropper_sample() -> String {
"all".into()
}
impl Default for ToolOptions {
fn default() -> Self {
Self {
@@ -624,6 +644,9 @@ impl Default for ToolOptions {
magnetic_pressure: false,
red_eye_pupil_size: 50.0,
red_eye_darken: 50.0,
eyedropper_size: 1,
eyedropper_sample: default_eyedropper_sample(),
eyedropper_ring: true,
}
}
}
+4 -2
View File
@@ -578,8 +578,9 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res
let i = app.session.active_index().ok_or("no document")?;
// Print Size assumes Photoshop's default 72 ppi screen resolution.
let dpi = app.session.active().map_or(72.0, |d| d.doc.resolution_dpi.max(1.0));
let size = app.session.active().map_or([0, 0], |d| [d.doc.size.width, d.doc.size.height]);
let z = if id == "view.twoHundredPercent" { 2.0 } else { 72.0 / dpi };
app.ui.views[i].zoom = z.clamp(0.01, 64.0);
app.ui.views[i].zoom = crate::zoom_levels::clamp(z, size);
Ok(json!({"zoom": app.ui.views[i].zoom}))
}
"view.fitLayersOnScreen" => fit_layers(app),
@@ -601,10 +602,11 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result<Value, String> {
if b.is_empty() {
b = st.doc.bounds();
}
let size = [st.doc.size.width, st.doc.size.height];
let area = app.last_canvas_rect.size();
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
let v = &mut app.ui.views[i];
v.zoom = ((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32).clamp(0.01, 64.0);
v.zoom = crate::zoom_levels::clamp(((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32), size);
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
v.fit_pending = false;
Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]}))
+17
View File
@@ -75,6 +75,23 @@ fn zoom_presets_and_fit_layers() {
assert!(app.ui.views[0].zoom > 10.0);
}
/// #1718 review: Print Size and Fit Layers on Screen go through `zoom_levels` too, so they reach
/// past the old 6400 % cap and stop at 12800 %.
#[test]
fn print_size_and_fit_layers_use_the_whole_zoom_range() {
let (mut app, ctx) = app_with(1);
// 72 / 1 ppi = 7200 %: within the range now (it was cut to 6400 %).
app.run("image.imageSize", json!({"resolution": 1, "resample": "none"})).unwrap();
menu(&mut app, &ctx, "view.printSize", json!({})).unwrap();
assert_eq!(app.ui.views[0].zoom, 72.0);
// A 1 px layer fills the view only far beyond 12800 %: Fit stops at the limit.
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": 40, "y": 30, "width": 1, "height": 1})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
menu(&mut app, &ctx, "view.fitLayersOnScreen", json!({})).unwrap();
assert_eq!(app.ui.views[0].zoom, crate::zoom_levels::MAX);
}
#[test]
fn arrange_layouts_floating_windows_and_matching() {
let (mut app, ctx) = app_with(3);
+74 -42
View File
@@ -1,7 +1,7 @@
//! Mouse wheel and trackpad navigation over the canvas (#293, #635), as in Photoshop:
//!
//! - The wheel scrolls up and down; ⇧ or ⌘/Ctrl + wheel scrolls sideways.
//! - ⌥/Alt + wheel zooms around the pointer in gentle steps, about 5% per wheel notch.
//! - ⌥/Alt + wheel zooms around the pointer, 10% per wheel notch, applied the frame it arrives.
//! - Preferences › General › Zoom with Scroll Wheel swaps the two: the wheel zooms and
//! ⌥/Alt + wheel scrolls.
//! - A trackpad pinch zooms around the pointer.
@@ -13,14 +13,16 @@
//! before egui sees them, so Ctrl + a notched mouse wheel scrolls sideways and the pinch still
//! zooms.
//!
//! egui smooths a wheel notch over several frames, so the Alt state is taken from the wheel
//! events themselves and kept until the next wheel event: releasing Alt while the last notch
//! is still settling must not turn the rest of it into a pan.
//! The target behaviour is exact ×1.1 steps, one per notch, with no easing in between
//! (measured frame by frame on a screen recording). egui smooths a wheel notch over several
//! frames, so zooming reads the wheel events themselves and ignores that smoothed tail; panning
//! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next
//! wheel event: releasing Alt while the tail is still settling must not turn it into a pan.
use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2};
/// Zoom factor of one wheel notch with ⌥/Alt held.
pub const ALT_NOTCH: f32 = 1.05;
/// Zoom factor of one wheel notch: every step is exactly ×1.1.
pub const NOTCH: f32 = 1.1;
/// A Ctrl + wheel event this soon (seconds) after a pinch step still belongs to the pinch, even
/// when it happens to be a whole notch.
@@ -38,8 +40,11 @@ pub enum Wheel {
/// Inputs of one frame's wheel gesture.
#[derive(Clone, Copy, Debug)]
pub struct Input {
/// Smoothed scroll delta (points, content direction).
/// Smoothed scroll delta (points, content direction); what panning uses.
pub scroll: Vec2,
/// This frame's wheel events for the gesture in progress, unsmoothed (points, content
/// direction); what zooming uses.
pub raw: Vec2,
/// egui's pinch zoom factor (1 = none).
pub zoom_delta: f32,
/// ⌥/Alt was held for the wheel gesture in progress.
@@ -55,20 +60,20 @@ pub fn classify(i: Input) -> Option<Wheel> {
if i.zoom_delta.is_finite() && i.zoom_delta > 0.0 && i.zoom_delta != 1.0 {
return Some(Wheel::Zoom(i.zoom_delta));
}
if !(i.scroll.x.is_finite() && i.scroll.y.is_finite()) || i.scroll == Vec2::ZERO {
return None;
}
let f = match (i.alt, i.zoom_with_wheel) {
(true, false) => {
// egui folds a scroll with Alt held into y. One notch (`notch` points) is 5%.
let notch = if i.notch.is_finite() && i.notch > 0.0 { i.notch } else { 40.0 };
ALT_NOTCH.powf((i.scroll.y + i.scroll.x) / notch)
}
// ⇧ and ⌘/Ctrl fold the wheel into x: those still scroll sideways.
(false, true) if i.scroll.y != 0.0 => (i.scroll.y / 200.0).exp(),
_ => return Some(Wheel::Pan(i.scroll)),
// One notch (`notch` points) is one ×1.1 step.
let step = |dy: f32| {
let notch = if i.notch.is_finite() && i.notch > 0.0 { i.notch } else { 40.0 };
let f = NOTCH.powf(dy / notch);
(f.is_finite() && f > 0.0 && f != 1.0).then_some(Wheel::Zoom(f))
};
(f.is_finite() && f > 0.0 && f != 1.0).then_some(Wheel::Zoom(f))
match (i.alt, i.zoom_with_wheel) {
// egui folds a scroll with Alt held into y. The smoothed tail of a notch is ignored.
(true, false) => step(i.raw.y + i.raw.x),
// ⇧ and ⌘/Ctrl fold the wheel into x: those still scroll sideways.
(false, true) if i.raw.y != 0.0 || i.scroll.y != 0.0 => step(i.raw.y),
_ if !(i.scroll.x.is_finite() && i.scroll.y.is_finite()) || i.scroll == Vec2::ZERO => None,
_ => Some(Wheel::Pan(i.scroll)),
}
}
/// Set egui's wheel modifiers (once, at setup): ⌘/Ctrl + wheel scrolls sideways like ⇧ + wheel
@@ -131,12 +136,20 @@ fn alt_id() -> Id {
/// gesture follows the latest wheel event.
pub fn read(ctx: &Context, zoom_with_wheel: bool) -> Option<Wheel> {
let notch = ctx.options(|o| o.input_options.line_scroll_speed);
let (scroll, zoom_delta, latest) = ctx.input(|i| {
let (scroll, zoom_delta, latest, wheel, page) = ctx.input(|i| {
let latest = i.events.iter().rev().find_map(|e| match e {
Event::MouseWheel { modifiers, .. } => Some(modifiers.alt && !modifiers.command && !modifiers.ctrl),
_ => None,
});
(i.smooth_scroll_delta, i.zoom_delta(), latest)
let wheel: Vec<(MouseWheelUnit, Vec2, Modifiers)> = i
.events
.iter()
.filter_map(|e| match e {
Event::MouseWheel { unit, delta, modifiers, .. } => Some((*unit, *delta, *modifiers)),
_ => None,
})
.collect();
(i.smooth_scroll_delta, i.zoom_delta(), latest, wheel, i.content_rect().height())
});
let alt = match latest {
Some(a) => {
@@ -145,7 +158,20 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool) -> Option<Wheel> {
}
None => ctx.data(|d| d.get_temp::<bool>(alt_id())).unwrap_or(false),
};
classify(Input { scroll, zoom_delta, alt, zoom_with_wheel, notch })
// The wheel events that belong to the gesture in progress: Alt ones, or plain ones (⇧ and
// ⌘/Ctrl wheel scroll sideways and never zoom).
let raw = wheel.iter().filter(|(_, _, m)| if alt { m.alt && !m.command && !m.ctrl } else { !m.alt && !m.shift && !m.command && !m.ctrl }).fold(
Vec2::ZERO,
|sum, (unit, delta, _)| {
sum + *delta
* match unit {
MouseWheelUnit::Point => 1.0,
MouseWheelUnit::Line => notch,
MouseWheelUnit::Page => page,
}
},
);
classify(Input { scroll, raw, zoom_delta, alt, zoom_with_wheel, notch })
}
#[cfg(test)]
@@ -155,7 +181,7 @@ mod tests {
use super::*;
fn input(scroll: Vec2, alt: bool) -> Input {
Input { scroll, zoom_delta: 1.0, alt, zoom_with_wheel: false, notch: 40.0 }
Input { scroll, raw: scroll, zoom_delta: 1.0, alt, zoom_with_wheel: false, notch: 40.0 }
}
fn wheel(delta: Vec2, modifiers: Modifiers) -> Event {
@@ -199,21 +225,27 @@ mod tests {
}
#[test]
fn alt_scroll_zooms_five_percent_per_notch() {
fn alt_scroll_zooms_ten_percent_per_notch() {
let Some(Wheel::Zoom(f)) = classify(input(Vec2::new(0.0, 40.0), true)) else { panic!("zoom") };
assert!((f - 1.05).abs() < 1e-5, "{f}");
assert!((f - 1.1).abs() < 1e-5, "{f}");
let Some(Wheel::Zoom(f)) = classify(input(Vec2::new(0.0, -40.0), true)) else { panic!("zoom") };
assert!((f - 1.0 / 1.05).abs() < 1e-5, "{f}");
// Smoothed over frames, the parts multiply back to one notch.
let parts = [12.0, 16.0, 8.0, 4.0];
let total: f32 = parts
.iter()
.map(|d| match classify(input(Vec2::new(0.0, *d), true)) {
Some(Wheel::Zoom(f)) => f,
_ => 1.0,
})
.product();
assert!((total - 1.05).abs() < 1e-4, "{total}");
assert!((f - 1.0 / 1.1).abs() < 1e-5, "{f}");
// Two notches in one frame are two steps, x1.1 each.
let Some(Wheel::Zoom(f)) = classify(input(Vec2::new(0.0, 80.0), true)) else { panic!("zoom") };
assert!((f - 1.21).abs() < 1e-4, "{f}");
}
#[test]
fn a_notch_zooms_the_frame_it_arrives_and_its_smoothed_tail_does_nothing() {
// egui keeps feeding a smoothed delta for several frames after the wheel event. Zooming
// must not follow it (that would ease the zoom), nor turn it into a pan.
let tail = Input { raw: Vec2::ZERO, ..input(Vec2::new(0.0, 12.0), true) };
assert_eq!(classify(tail), None);
let tail = Input { raw: Vec2::ZERO, zoom_with_wheel: true, ..input(Vec2::new(0.0, 12.0), false) };
assert_eq!(classify(tail), None);
// Plain scrolling without the preference still pans from the smoothed delta.
let pan = Input { raw: Vec2::ZERO, ..input(Vec2::new(0.0, 12.0), false) };
assert_eq!(classify(pan), Some(Wheel::Pan(Vec2::new(0.0, 12.0))));
}
#[test]
@@ -228,8 +260,8 @@ mod tests {
#[test]
fn zoom_with_scroll_wheel_preference_swaps_wheel_and_alt_wheel() {
let pref = |scroll, alt| Input { zoom_with_wheel: true, ..input(scroll, alt) };
let Some(Wheel::Zoom(f)) = classify(pref(Vec2::new(0.0, 200.0), false)) else { panic!("zoom") };
assert!((f - std::f32::consts::E).abs() < 1e-4);
let Some(Wheel::Zoom(f)) = classify(pref(Vec2::new(0.0, 40.0), false)) else { panic!("zoom") };
assert!((f - 1.1).abs() < 1e-5, "{f}");
assert_eq!(classify(pref(Vec2::new(0.0, 40.0), true)), Some(Wheel::Pan(Vec2::new(0.0, 40.0))), "⌥ + wheel scrolls");
assert_eq!(classify(pref(Vec2::new(40.0, 0.0), false)), Some(Wheel::Pan(Vec2::new(40.0, 0.0))), "sideways still scrolls");
}
@@ -245,12 +277,12 @@ mod tests {
assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "{m:?} + wheel scrolls sideways: {pan:?} {zoom}");
}
let (pan, zoom) = one(Modifiers::ALT);
assert!(pan == Vec2::ZERO && (zoom - 1.05).abs() < 1e-4, "⌥ + wheel zooms 5%: {pan:?} {zoom}");
assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "⌥ + wheel zooms 10%, at once: {pan:?} {zoom}");
// Zoom with Scroll Wheel on: swapped.
let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::ALT)]], true, false);
assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "{pan:?} {zoom}");
let (pan, zoom) = gesture(vec![vec![wheel(notch, Modifiers::NONE)]], true, false);
assert!(pan == Vec2::ZERO && zoom > 1.1, "{pan:?} {zoom}");
assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "{pan:?} {zoom}");
let (pan, zoom) = gesture(vec![vec![wheel(notch, CTRL)]], true, false);
assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "⌘/Ctrl + wheel still scrolls sideways: {pan:?} {zoom}");
}
@@ -294,7 +326,7 @@ mod tests {
for alt in [false, true] {
for notch in [0.0, -1.0, f32::NAN, 40.0] {
for zd in [f32::NAN, 0.0, -1.0, f32::INFINITY, 1.0] {
let i = Input { scroll: Vec2::new(0.0, s), zoom_delta: zd, alt, zoom_with_wheel: !alt, notch };
let i = Input { scroll: Vec2::new(0.0, s), raw: Vec2::new(0.0, s), zoom_delta: zd, alt, zoom_with_wheel: !alt, notch };
if let Some(Wheel::Zoom(f)) = classify(i) {
assert!(f.is_finite() && f > 0.0, "{f} from {i:?}");
}
+157
View File
@@ -0,0 +1,157 @@
//! The canvas zoom range and Photoshop's Zoom In / Zoom Out ladder, measured on Photoshop 25.4:
//!
//! - the zoom runs up to 12800 %, and down to where the document's shorter side is 2 px
//! (4 × 4 px: 50 %; 2000 × 1500 px: 0.1333 %);
//! - Zoom In / Zoom Out (⌘+ / ⌘−) and a Zoom tool click step along [`LADDER`], from any zoom to
//! the next step (880 %: in to 1200 %, out to 800 %); they go no lower than 0.1 %, or the
//! document's minimum when that is higher;
//! - ⌥ + wheel multiplies the zoom by 1.1 a notch (`wheel_nav`), and a notch that would pass a
//! limit stops exactly on it.
//!
//! Every way of setting the zoom (wheel, Zoom tool, menu, zoom fields, Navigator, control
//! channel) clamps through here, so no control can pull the zoom back to a narrower range of
//! its own.
/// 12800 %.
pub const MAX: f32 = 128.0;
/// Zoom In / Zoom Out steps (1 = 100 %).
pub const LADDER: [f32; 33] = [
0.001,
0.002,
0.003,
0.004,
0.005,
0.007,
0.01,
0.015,
0.02,
0.03,
0.04,
0.05,
0.0625,
1.0 / 12.0,
0.125,
1.0 / 6.0,
0.25,
1.0 / 3.0,
0.5,
2.0 / 3.0,
1.0,
2.0,
3.0,
4.0,
5.0,
6.0,
7.0,
8.0,
12.0,
16.0,
32.0,
64.0,
128.0,
];
/// The lowest zoom for a document of `size` px: its shorter side 2 px on screen.
pub fn min(size: [u32; 2]) -> f32 {
match size[0].min(size[1]) {
// No size known yet (a view before its first frame).
0 => LADDER[0],
short => (2.0 / short as f32).min(MAX),
}
}
/// `zoom` within the range for a document of `size` px (a non-finite zoom becomes 100 %).
pub fn clamp(zoom: f32, size: [u32; 2]) -> f32 {
if zoom.is_finite() { zoom.min(MAX).max(min(size)) } else { 1.0 }
}
/// A zoom field's range in percent for `view`: the zoom range, and always the view's zoom (a
/// field narrower than the zoom would clamp it and write it back every frame, which once made
/// the wheel slide the image towards the pointer at 3200 %).
pub fn percent_range(view: &crate::state::View) -> std::ops::RangeInclusive<f32> {
let z = if view.zoom.is_finite() { view.zoom } else { 1.0 };
(min(view.doc_size).min(z) * 100.0)..=(MAX.max(z) * 100.0)
}
/// Zoom In (`dir` > 0) or Zoom Out (`dir` < 0) from `zoom`, for a document of `size` px.
pub fn step(zoom: f32, dir: i32, size: [u32; 2]) -> f32 {
let z = clamp(zoom, size);
let next = if dir > 0 {
LADDER.iter().copied().find(|s| *s > z * 1.001).unwrap_or(MAX)
} else {
// Below the ladder's lowest step (reached by the wheel), Zoom Out stays put.
LADDER.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(z)
};
clamp(next, size)
}
#[cfg(test)]
mod tests {
use super::*;
const PHOTO: [u32; 2] = [2000, 1500];
fn pct(z: f32) -> f32 {
(z * 100.0 * 1000.0).round() / 1000.0
}
#[test]
fn the_ladder_is_photoshops() {
// Photoshop 25.4, View › Zoom In from 100 %, then Zoom Out from 100 % (2000 × 1500 px).
let mut z = 1.0;
let mut up = Vec::new();
while z < MAX {
z = step(z, 1, PHOTO);
up.push(pct(z));
}
assert_eq!(up, [200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 1200.0, 1600.0, 3200.0, 6400.0, 12800.0]);
assert_eq!(step(MAX, 1, PHOTO), MAX, "Zoom In stops at 12800 %");
let mut z = 1.0;
let mut down = Vec::new();
for _ in 0..20 {
z = step(z, -1, PHOTO);
down.push(pct(z));
}
assert_eq!(
down,
[66.667, 50.0, 33.333, 25.0, 16.667, 12.5, 8.333, 6.25, 5.0, 4.0, 3.0, 2.0, 1.5, 1.0, 0.7, 0.5, 0.4, 0.3, 0.2, 0.133],
"the last step is the document's minimum (1500 px tall: 2 / 1500)"
);
assert_eq!(step(z, -1, PHOTO), z, "Zoom Out stops at the minimum");
}
#[test]
fn off_ladder_zooms_step_to_the_next_rung() {
assert_eq!(step(8.8, 1, PHOTO), 12.0);
assert_eq!(step(8.8, -1, PHOTO), 8.0);
assert_eq!(step(1.331, 1, PHOTO), 2.0);
assert_eq!(step(1.331, -1, PHOTO), 1.0);
}
#[test]
fn the_minimum_follows_the_shorter_side() {
assert_eq!(min([4, 4]), 0.5);
assert_eq!(pct(min([300, 1000])), 0.667);
assert_eq!(pct(min([1000, 300])), 0.667);
// A huge document: the wheel goes below 0.1 %, Zoom Out stops there.
let big = [12_000, 12_000];
assert!(min(big) < 0.001);
assert_eq!(step(0.0015, -1, big), 0.001);
assert_eq!(step(0.001, -1, big), 0.001);
assert_eq!(step(0.0007, -1, big), 0.0007, "below the ladder Zoom Out stays put");
// A 1 px document can't go below 200 %.
assert_eq!(min([1, 1]), 2.0);
assert_eq!(step(2.0, -1, [1, 1]), 2.0);
assert_eq!(min([0, 0]), 0.001, "unknown size");
}
#[test]
fn clamp_keeps_the_range_and_survives_bad_input() {
assert_eq!(clamp(500.0, PHOTO), MAX);
assert_eq!(clamp(0.0, PHOTO), min(PHOTO));
assert_eq!(clamp(f32::NAN, PHOTO), 1.0);
assert_eq!(clamp(f32::INFINITY, PHOTO), 1.0);
assert_eq!(clamp(3.3, PHOTO), 3.3);
}
}
+17 -19
View File
@@ -12,9 +12,6 @@ use crate::canvas::ViewXform;
use crate::paint_mouse::Buttons;
use crate::state::View;
/// Zoom limits of the canvas (1 % … 6400 %).
pub const MIN_ZOOM: f32 = 0.01;
pub const MAX_ZOOM: f32 = 64.0;
/// Horizontal drag (screen points) that doubles, or halves, the zoom.
pub const DOUBLING: f32 = 100.0;
@@ -34,12 +31,12 @@ fn id() -> egui::Id {
egui::Id::new("pc-zoom-tool-drag")
}
/// The scrubby zoom for a horizontal drag of `dx` points from a press at zoom `zoom0`.
pub fn scrub_zoom(zoom0: f32, dx: f32) -> f32 {
/// The scrubby zoom for a horizontal drag of `dx` points from a press at zoom `zoom0`, on a
/// document of `size` px (`zoom_levels` sets the range).
pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 {
// ±40 doublings already span far more than the zoom range; it keeps the power finite.
let e = if dx.is_finite() { (dx / DOUBLING).clamp(-40.0, 40.0) } else { 0.0 };
let z = zoom0 * e.exp2();
if z.is_finite() { z.clamp(MIN_ZOOM, MAX_ZOOM) } else { 1.0 }
crate::zoom_levels::clamp(zoom0 * e.exp2(), size)
}
/// Set `view` to `zoom` with document point `doc` shown at screen point `screen`.
@@ -65,7 +62,7 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) {
match z.rect_to.as_mut() {
Some(to) => *to = p,
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x)),
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size)),
}
ctx.data_mut(|d| d.insert_temp(id(), z));
}
@@ -83,11 +80,11 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool) {
if out || r.width() < 4.0 || r.height() < 4.0 {
let doc = xf.to_doc(anchor);
anchor_view(view, xf, anchor, doc, crate::canvas::zoom_step(view.zoom, if out { -1 } else { 1 }).clamp(MIN_ZOOM, MAX_ZOOM));
anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size));
return;
}
let k = (xf.rect.width() / r.width()).min(xf.rect.height() / r.height());
let zoom = (view.zoom * k).clamp(MIN_ZOOM, MAX_ZOOM);
let zoom = crate::zoom_levels::clamp(view.zoom * k, view.doc_size);
let c = xf.to_doc(r.center());
anchor_view(view, xf, xf.rect.center(), c, zoom);
}
@@ -110,15 +107,16 @@ mod tests {
#[test]
fn scrub_zoom_is_continuous_and_clamped() {
assert_eq!(scrub_zoom(1.0, 0.0), 1.0);
assert!((scrub_zoom(1.0, DOUBLING) - 2.0).abs() < 1e-5);
assert!((scrub_zoom(1.0, -DOUBLING) - 0.5).abs() < 1e-5);
const DOC: [u32; 2] = [400, 300];
assert_eq!(scrub_zoom(1.0, 0.0, DOC), 1.0);
assert!((scrub_zoom(1.0, DOUBLING, DOC) - 2.0).abs() < 1e-5);
assert!((scrub_zoom(1.0, -DOUBLING, DOC) - 0.5).abs() < 1e-5);
// Monotone: every point of drag to the right zooms in a little more.
let zs: Vec<f32> = (0..50).map(|i| scrub_zoom(0.7, i as f32 * 3.0)).collect();
let zs: Vec<f32> = (0..50).map(|i| scrub_zoom(0.7, i as f32 * 3.0, DOC)).collect();
assert!(zs.windows(2).all(|w| w[1] > w[0]));
assert_eq!(scrub_zoom(1.0, 1e6), MAX_ZOOM);
assert_eq!(scrub_zoom(1.0, -1e6), MIN_ZOOM);
assert_eq!(scrub_zoom(2.0, f32::NAN), 2.0);
assert_eq!(scrub_zoom(1.0, 1e6, DOC), crate::zoom_levels::MAX);
assert_eq!(scrub_zoom(1.0, -1e6, DOC), crate::zoom_levels::min(DOC));
assert_eq!(scrub_zoom(2.0, f32::NAN, DOC), 2.0);
}
fn harness(scrubby: bool) -> Harness<'static, PhotocraftApp> {
@@ -179,7 +177,7 @@ mod tests {
h.event(Event::PointerMoved(p0 - vec2(60.0, 0.0)));
h.step();
let z = h.state().current_zoom();
assert!(z < z0 && (z - scrub_zoom(z0, -60.0)).abs() < 1e-4, "{z0} -> {z}");
assert!(z < z0 && (z - scrub_zoom(z0, -60.0, h.state().ui.views[0].doc_size)).abs() < 1e-4, "{z0} -> {z}");
press(&mut h, p0 - vec2(60.0, 0.0), false);
h.run_steps(2);
// Release keeps the zoom; nothing was painted or recorded.
@@ -192,7 +190,7 @@ mod tests {
press(&mut h, p0, true);
press(&mut h, p0, false);
h.run_steps(2);
assert_eq!(h.state().current_zoom(), crate::canvas::zoom_step(zc, 1));
assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(zc, 1, h.state().ui.views[0].doc_size));
}
#[test]
+1
View File
@@ -42,6 +42,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks.
Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them.
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once
- Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor`
+3 -1
View File
@@ -197,7 +197,9 @@ claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp \
```
`doc_inspect` (and the engine command `document.inspect`) reports the layer tree with kinds,
bounds, masks, selection, effects (`effects.items[].kind`), smart filters (`smartFilters[]`), type
bounds, masks, selection, effects (`effects.items[].kind`), smart filters (`smartFilters[]`), smart
object sources (`smartSource`: `embedded` with its file name, or `linked` with a file path or, for a
PSD placed layer, the `Idnt` uuid its duplicates share), type
text, adjustment settings, channels and history, so agents can verify what they did without a
screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks
(title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align,
+2 -1
View File
@@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 11 | 14 | 8 | |
| [Tools](#tools) | 12 | 14 | 8 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 141 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
@@ -154,6 +154,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo |
| TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error |
| TOOL-214-17 | Rotate View tool: drag to turn the view, Reset View, Match Rotation copies the angle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Tool::RotateView in the Hand flyout; view.rotateView / view.resetView; Match Rotation copies views[].rotation; enhanced_controls.rotate_view_with_trackpad is read |
| TOOL-1649-1 | Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring | done | [#1649](https://github.com/storytold/photocraft/issues/1649) | document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing |
## Workspace and UI
+7
View File
@@ -235,3 +235,10 @@ target = "Rotate View tool: drag to turn the view, Reset View, Match Rotation co
status = "done"
issue = 214
note = "Tool::RotateView in the Hand flyout; view.rotateView / view.resetView; Match Rotation copies views[].rotation; enhanced_controls.rotate_view_with_trackpad is read"
[[item]]
id = "TOOL-1649-1"
target = "Eyedropper Sample Size (Point, 3×3 … 101×101 Average), Sample layers, Show Sampling Ring"
status = "done"
issue = 1649
note = "document.sampleColor (sample_cmds.rs): average of the square clipped to the canvas, in the document's model and depth; eyedropper_ui.rs options bar; shared with Alt-click, dialog eyedroppers and the Info panel; ui.set eyedropperSampleSize/eyedropperSample/eyedropperRing. Color Sampler tool still missing"
+7
View File
@@ -0,0 +1,7 @@
042286653fbae5b085bef4a4e626145385ea6e82e817f166b4904abcef64457c E_1__C106743_gredos.ORF
32e463ae38ae8d8cd1a356af0fc479f2513366c41801d46d4b167cf899656892 CRW_4061.DNG
6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c IMG_4059.CR2
7c24fefb1f73ad45c42a0be0078e15b7e91deab1d623efceebfc3c4d0d06135c P1000475.RW2
880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8 JD1_8203.NEF
ab0d54e3819c9372df0fa98c8686dba0ae87c9d43526a5dd6fd26bd2a664483d _7061961_copy.ORF
f602f8ddeeb9b062f60bfdd3ba0d560c8e54f1f64dbc20e18df5222e2f37a428 DSC00009.ARW
+85 -20
View File
@@ -5,25 +5,36 @@ use std::path::PathBuf;
use std::process::Command;
use crate::corpus_pins::{self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_URL, PSD_TOOLS_COMMIT};
use crate::pinned::USER_AGENT;
use crate::pinned::{PinnedCorpus, USER_AGENT};
use crate::{cargo, root, run};
/// Crates with corpus tests (behind their `corpus` feature).
pub const CORPUS_CRATES: &[&str] = &["photocraft-psd", "photocraft-codecs", "photocraft-io", "photocraft-engine"];
/// Opt-in corpus crates: fetched and tested only with `test-corpus --pixls`.
pub const PIXLS_CRATES: &[&str] = &["photocraft-raw"];
/// Corpus crates with a `heif` feature: test-corpus enables it so the HEIF corpus tests run.
const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"];
/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates).
const CRITICAL: &[&str] =
&["crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/", "crates/affinity/"];
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --affinity | --photoshop [--local]] [--update-manifest]`
const CRITICAL: &[&str] = &[
"crates/psd/",
"crates/io/",
"crates/codecs/",
"crates/heif/",
"crates/compose/",
"crates/gpu/",
"crates/text/",
"crates/format/",
"crates/affinity/",
"crates/raw/",
];
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local]] [--update-manifest]`
pub fn cmd(args: &[&str]) -> Result<(), String> {
let update = args.contains(&"--update-manifest");
let mut did = false;
if args.contains(&"--all") {
return fetch_all(args.contains(&"--local"));
return fetch_all(args.contains(&"--local"), update);
}
if args.contains(&"--pngsuite") || args.contains(&"--download") {
fetch_pngsuite()?;
@@ -41,6 +52,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> {
corpus_pins::HEIF.fetch(update)?;
did = true;
}
if args.contains(&"--pixls") {
crate::pinned::fetch_pixls(update)?;
did = true;
}
if args.contains(&"--exr") {
corpus_pins::EXR.fetch(update)?;
did = true;
@@ -75,20 +90,28 @@ fn local_clone() -> Result<PathBuf, String> {
}
/// Fetches every corpus that is missing or stale (verified ones are left alone). With `local`,
/// `corpus/photoshop` is copied from the authoring clone of photocraft-corpus instead.
pub fn fetch_all(local: bool) -> Result<(), String> {
/// `corpus/photoshop` is copied from the authoring clone of photocraft-corpus instead. With
/// `update`, every pinned corpus is refetched and its manifest rewritten (`--update-manifest`).
pub fn fetch_all(local: bool, update: bool) -> Result<(), String> {
fetch_pngsuite()?;
for c in corpus_pins::ALL {
if local && std::ptr::eq(*c, &corpus_pins::PHOTOSHOP) {
c.fetch_local(&local_clone()?, false)?;
} else {
c.fetch(false)?;
}
}
fetch_pinned(local, update, fetch_one)?;
println!("all corpora present and verified under {}", root().join("corpus").display());
Ok(())
}
/// Calls `fetch(corpus, from_clone, update)` for every pinned corpus; `from_clone` is set for
/// `corpus/photoshop` when `local`.
fn fetch_pinned(local: bool, update: bool, mut fetch: impl FnMut(&PinnedCorpus, bool, bool) -> Result<(), String>) -> Result<(), String> {
for c in corpus_pins::ALL {
fetch(c, local && std::ptr::eq(*c, &corpus_pins::PHOTOSHOP), update)?;
}
Ok(())
}
fn fetch_one(c: &PinnedCorpus, from_clone: bool, update: bool) -> Result<(), String> {
if from_clone { c.fetch_local(&local_clone()?, update) } else { c.fetch(update) }
}
fn status(present: bool) -> &'static str {
if present { "present" } else { "missing" }
}
@@ -119,7 +142,8 @@ fn list() {
corpus/affinity/ [{}] 21 public Affinity 1–3 documents (CC0, MIT): vector-art, AFDesignLoad,
Jac21/Branding, AssetStoreTemplate; manifest xtask/affinity-corpus.sha256. Fetch: --affinity
corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite
corpus/tiff/, corpus/raw/ optional, copied in by hand
corpus/pixls/ [opt-in] real camera raws from raw.pixls.us (public domain), one per decode
path plus the known-unsupported packed ORF. Fetch: --pixls (opt-in)
Pins: xtask/src/corpus_pins.rs. Moving one: change it, then --<name> --update-manifest.",
corpus.display(),
@@ -180,6 +204,7 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> {
Some(i) => (&args[..i], &args[i + 1..]),
None => (args, &[][..]),
};
let mut raw_changed = false;
if ours.contains(&"--changed") {
let changed = changed_files()?;
let hits: Vec<&String> = changed.iter().filter(|f| CRITICAL.iter().any(|c| f.starts_with(c))).collect();
@@ -188,6 +213,7 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> {
return Ok(());
}
println!("test-corpus --changed: {} critical files changed (e.g. {}); running the corpus tests", hits.len(), hits[0]);
raw_changed = hits.iter().any(|f| f.starts_with("crates/raw/"));
}
let mut crates: Vec<String> = Vec::new();
let mut it = ours.iter();
@@ -196,19 +222,28 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> {
"-p" | "--package" => {
let name = it.next().ok_or("-p needs a crate name")?;
let full = if name.starts_with("photocraft-") { (*name).to_string() } else { format!("photocraft-{name}") };
if !CORPUS_CRATES.contains(&full.as_str()) {
return Err(format!("{full} has no corpus tests (crates: {})", CORPUS_CRATES.join(", ")));
if !CORPUS_CRATES.contains(&full.as_str()) && !PIXLS_CRATES.contains(&full.as_str()) {
return Err(format!("{full} has no corpus tests (crates: {} or the opt-in {})", CORPUS_CRATES.join(", "), PIXLS_CRATES.join(", ")));
}
crates.push(full);
}
"--changed" | "--local" => {}
"--changed" | "--local" | "--pixls" => {}
other => return Err(format!("test-corpus: unknown argument `{other}`")),
}
}
if crates.is_empty() {
crates = CORPUS_CRATES.iter().map(|s| (*s).to_string()).collect();
}
fetch_all(ours.contains(&"--local"))?;
// `--pixls` opts in (and fetches), and a raw change under --changed does too: the raw
// corpus tests are not part of the default set until a maintainer decides otherwise.
let wants_pixls = ours.contains(&"--pixls") || crates.iter().any(|c| PIXLS_CRATES.contains(&c.as_str())) || raw_changed;
if wants_pixls && !crates.iter().any(|c| c == "photocraft-raw") {
crates.push("photocraft-raw".to_string());
}
if wants_pixls {
crate::pinned::fetch_pixls(false)?;
}
fetch_all(ours.contains(&"--local"), false)?;
let mut c = cargo();
c.args(["test", "--release", "--lib", "--tests"]);
for k in &crates {
@@ -222,3 +257,33 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> {
}
run(c, &format!("cargo test --release --features corpus,heif ({})", crates.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
/// The flags each pinned corpus is fetched with by `fetch_all(local, update)`.
fn calls(local: bool, update: bool) -> Vec<(&'static str, bool)> {
let mut seen = Vec::new();
let r = fetch_pinned(local, update, |c, _, u| {
seen.push((c.name, u));
Ok(())
});
assert_eq!(r, Ok(()));
seen
}
#[test]
fn all_with_update_manifest_updates_every_pinned_corpus() {
for local in [false, true] {
let seen = calls(local, true);
assert_eq!(seen.len(), corpus_pins::ALL.len());
assert!(seen.iter().all(|(_, u)| *u), "--all --update-manifest dropped the flag: {seen:?}");
}
}
#[test]
fn all_without_update_manifest_only_verifies() {
assert!(calls(false, false).iter().all(|(_, u)| !*u));
}
}
+17 -1
View File
@@ -224,6 +224,22 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus {
)
},
};
/// Every pinned corpus, in fetch order.
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY];
/// `corpus/pixls`: real camera raws from <https://raw.pixls.us> (released into the public
/// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path
/// `photocraft-raw` supports, plus the packed-ORF known-unsupported oracle. Fetched as single
/// files (the server 301-redirects `/data/` to `/download/`), verified against
/// `xtask/pixls.sha256`.
pub const PIXLS_BASE: &str = "https://raw.pixls.us/data/";
/// (url path, file name in `corpus/pixls`)
pub const PIXLS_FILES: &[(&str, &str)] = &[
("Canon/PowerShot%20SX50%20HS/IMG_4059.CR2", "IMG_4059.CR2"),
("Canon/PowerShot%20SX50%20HS/CRW_4061.DNG", "CRW_4061.DNG"),
("Nikon/D3/JD1_8203.NEF", "JD1_8203.NEF"),
("Sony/DSC-RX0/DSC00009.ARW", "DSC00009.ARW"),
("Panasonic/DC-G9/P1000475.RW2", "P1000475.RW2"),
("Olympus/E-1/E_1__C106743_gredos.ORF", "E_1__C106743_gredos.ORF"),
("Olympus/E-5/_7061961_copy.ORF", "_7061961_copy.ORF"),
];
+5 -4
View File
@@ -25,13 +25,14 @@ commands:
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
corpus [--all | --pngsuite | --psd | --psd-tools | --photoshop] [--local] [--update-manifest]
corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest]
show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch
them into corpus/ (pinned commits, sha256-verified; --all = every corpus;
them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls,
--photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO)
test-corpus [-p <crate>]... [--changed] [--local] [-- <test args>]
test-corpus [-p <crate>]... [--changed] [--pixls] [--local] [-- <test args>]
fetch every corpus, then cargo test --release --features corpus on the corpus
crates; --changed runs only if psd/io/codecs/compose/gpu/text/format changed;
crates; --changed runs only if psd/io/codecs/compose/gpu/text/format/raw changed;
--pixls (or -p raw) also fetches corpus/pixls and runs the raw corpus tests;
--local takes corpus/photoshop from the photocraft-corpus authoring clone
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity Photoshop menu parity; rewrites docs/parity.md
+49
View File
@@ -263,3 +263,52 @@ impl PinnedCorpus {
self.install(&found, |f| Some(src.join(&f[up.prefix.len()..])), &extras, true, &format!("copied from the local clone {}", clone.display()))
}
}
/// Hex sha256 of a corpus file (reused by every corpus fetch, `xtask/pixls.sha256` included).
fn file_hex(path: &std::path::Path) -> Option<String> {
std::fs::read(path).ok().map(|b| crate::sha256::hex(&b))
}
/// Fetches `corpus/pixls` — the raw.pixls.us camera files pinned in `corpus_pins::PIXLS_FILES` —
/// and verifies them against `xtask/pixls.sha256`, the same manifest form every corpus uses.
/// raw.pixls.us serves single files and redirects `/data/` to `/download/`, so this is a plain
/// per-file download rather than a git upstream; the manifest discipline is the same
/// (`--update-manifest` re-pins). Opt-in: not part of `--all` or CI until a maintainer says so.
pub fn fetch_pixls(update: bool) -> Result<(), String> {
let dest = crate::root().join("corpus").join("pixls");
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let manifest = crate::root().join("xtask").join("pixls.sha256");
if update {
let mut names: Vec<&str> = crate::corpus_pins::PIXLS_FILES.iter().map(|(_, n)| *n).collect();
names.sort_unstable();
let mut out = String::new();
for name in names {
let sum = file_hex(&dest.join(name)).ok_or_else(|| format!("pixls {name}: not downloaded yet"))?;
out.push_str(&format!("{sum} {name}\n"));
}
std::fs::write(&manifest, out).map_err(|e| format!("write {}: {e}", manifest.display()))?;
println!("pixls: re-hashed {} files into {}", crate::corpus_pins::PIXLS_FILES.len(), manifest.display());
return Ok(());
}
let want: std::collections::HashMap<String, String> = std::fs::read_to_string(&manifest)
.map_err(|e| format!("read {}: {e} (run `cargo xtask corpus --pixls` once)", manifest.display()))?
.lines()
.filter_map(|l| l.split_once(" ").map(|(s, n)| (n.to_string(), s.to_string())))
.collect();
for (path, name) in crate::corpus_pins::PIXLS_FILES {
let file = dest.join(name);
if file_hex(&file).as_deref() == want.get(*name).map(String::as_str) {
continue;
}
let url = format!("{}{}", crate::corpus_pins::PIXLS_BASE, path);
let mut curl = Command::new("curl");
curl.args(["-fsSL", "--retry", "3", "-A", USER_AGENT, "-o"]).arg(&file).arg(&url);
run(curl, &format!("curl {url}"))?;
let got = file_hex(&file).ok_or_else(|| format!("pixls {name}: unreadable"))?;
if Some(got.as_str()) != want.get(*name).map(String::as_str) {
return Err(format!("pixls {name}: sha256 mismatch (pin the file you meant, then --update-manifest)"));
}
}
println!("pixls: {} files verified in {}", crate::corpus_pins::PIXLS_FILES.len(), dest.display());
Ok(())
}