mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 06:34:12 -04:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df0e858198 | ||
|
|
67cf1ea6e4 | ||
|
|
1f0f8fc13e | ||
|
|
a04469a099 | ||
|
|
9cc8e17be4 | ||
|
|
0760e1c70c | ||
|
|
db09f7f575 | ||
|
|
6097ef8102 | ||
|
|
c7296f3d7b | ||
|
|
1c5976843e | ||
|
|
a61d8b305c | ||
|
|
744a0c7c4e | ||
|
|
ac9c7b057b | ||
|
|
42e60e3602 | ||
|
|
beb80bc5dc | ||
|
|
819651f3c0 | ||
|
|
74112064cc | ||
|
|
2e41caf897 | ||
|
|
4b7db4088b | ||
|
|
0b958be52a | ||
|
|
a94dd19d79 | ||
|
|
8cdea53ced | ||
|
|
cc8d3c3f77 | ||
|
|
0103edfdfd | ||
|
|
3bfaef668c | ||
|
|
5d0800df7a | ||
|
|
6ce21609a2 | ||
|
|
acc27e0fd0 | ||
|
|
65f30dd9e3 | ||
|
|
4a360c3780 | ||
|
|
98e48fc77b | ||
|
|
6ba1419286 | ||
|
|
7a3347b9a0 | ||
|
|
92cef00c2f | ||
|
|
b692ed451c | ||
|
|
fee28f3a1b | ||
|
|
52b9d2453f | ||
|
|
622d6b3233 | ||
|
|
c3da650ebb | ||
|
|
a5d92c231c | ||
|
|
f1433f4ab2 |
No files matched your search
@@ -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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 颜色
|
||||
|
||||
|
@@ -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.
|
@@ -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);
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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]];
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user