Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec379db46 | ||
|
|
4d2f34fb35 | ||
|
|
b306305472 | ||
|
|
832b8ada51 | ||
|
|
bf97105cf7 | ||
|
|
eaaf859853 | ||
|
|
2bd13c1500 | ||
|
|
cc8d617459 | ||
|
|
d3ceaf0e79 | ||
|
|
32cedb3153 | ||
|
|
86d88de2ee | ||
|
|
d01ebd7953 | ||
|
|
54b037a90a | ||
|
|
1e27991cf1 | ||
|
|
a1e99fad9e | ||
|
|
ff9d74f4e5 | ||
|
|
55ecd900ae | ||
|
|
2d47ba3207 | ||
|
|
7b47ba7894 | ||
|
|
1b5ba5eba7 | ||
|
|
8d79bf082e | ||
|
|
d316d97d80 | ||
|
|
2ab6e973c6 | ||
|
|
c395789026 | ||
|
|
6aa599f869 | ||
|
|
f6f5e6f1b7 | ||
|
|
7094ba1b6e |
No files matched your search
+5
-4
@@ -24,7 +24,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
|---|---:|---|---|
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF and CR3 have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
@@ -47,8 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit
|
||||
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
|
||||
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); then ORF, then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
no LGPL dependency); compressed NEF (#86) is the template.
|
||||
@@ -186,7 +187,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF and CR3 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
@@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
let (backup_backend, backup_files) = (backend.clone(), files);
|
||||
let restore_backend = backend.clone();
|
||||
Services {
|
||||
picker: None,
|
||||
backup_library: Some(Box::new(move |session: &mut Session| {
|
||||
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
|
||||
// the photos' own names for the originals in the zip
|
||||
|
||||
@@ -281,8 +281,42 @@ fn windows_open_url_command(url: &str) -> std::process::Command {
|
||||
c
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
/// The platform services; `ctx` repaints when a file dialog closes.
|
||||
fn services(ctx: egui::Context) -> Services {
|
||||
Services {
|
||||
// Commands' file dialogs run on their own thread (#191): a dialog on the UI thread
|
||||
// stopped the window answering the compositor, which then reported the app as hung.
|
||||
picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| {
|
||||
use lightcraft_ui_egui::pick::PickKind;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let repaint = ctx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
let mut d = rfd::FileDialog::new();
|
||||
if let Some(t) = &req.title {
|
||||
d = d.set_title(t);
|
||||
}
|
||||
if let Some((name, exts)) = &req.filter {
|
||||
d = d.add_filter_nocase(name.clone(), exts);
|
||||
}
|
||||
if let Some(n) = &req.file_name {
|
||||
d = d.set_file_name(n);
|
||||
}
|
||||
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
|
||||
let paths: Vec<String> = match req.kind {
|
||||
PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(),
|
||||
PickKind::File => d.pick_file().map(s).into_iter().collect(),
|
||||
PickKind::Folder => d.pick_folder().map(s).into_iter().collect(),
|
||||
PickKind::Save => d.save_file().map(s).into_iter().collect(),
|
||||
};
|
||||
// the app may have quit meanwhile: nobody to tell
|
||||
let _ = tx.send(paths);
|
||||
repaint.request_repaint();
|
||||
})
|
||||
.map_err(|e| format!("could not start the file dialog: {e}"))?;
|
||||
Ok(rx)
|
||||
})),
|
||||
pick_lightroom_catalog: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog"))
|
||||
@@ -614,7 +648,7 @@ fn main() -> eframe::Result {
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = LightcraftApp::new(session, services());
|
||||
let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone()));
|
||||
if let Some(ui) = prefs {
|
||||
app.ui = ui;
|
||||
}
|
||||
|
||||
@@ -336,12 +336,13 @@ impl Photo {
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF, RW2, RAF and CR3 readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw()
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
|
||||
@@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?;
|
||||
let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford)
|
||||
let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling
|
||||
let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode
|
||||
let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded
|
||||
|
||||
let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…)
|
||||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) };
|
||||
|
||||
@@ -108,6 +108,57 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
decode_with_fallback(bytes, opts, None)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The
|
||||
/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame
|
||||
/// type or component count, or when its markers don't run to an end-of-image marker after the
|
||||
/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
return Err(Error::Malformed(F, "no frame header".into()));
|
||||
}
|
||||
check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?;
|
||||
if !matches!(m.sof, 0xC0..=0xC3) {
|
||||
return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG"));
|
||||
}
|
||||
if !matches!(m.components, 1 | 3 | 4) {
|
||||
return Err(Error::Malformed(F, format!("{} colour components", m.components)));
|
||||
}
|
||||
if m.first_scan_components == 0 || !reaches_eoi(bytes) {
|
||||
return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into()));
|
||||
}
|
||||
let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((m.width, m.height, orientation))
|
||||
}
|
||||
|
||||
/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker
|
||||
/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments
|
||||
/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not
|
||||
/// markers that end it.
|
||||
fn reaches_eoi(b: &[u8]) -> bool {
|
||||
let mut p = 2usize;
|
||||
loop {
|
||||
// entropy-coded data (or garbage between segments, as `parse_markers` tolerates it)
|
||||
let Some(skip) = b.get(p..).and_then(find_ff) else { return false };
|
||||
p = p.saturating_add(skip);
|
||||
let Some(&marker) = b.get(p.saturating_add(1)) else { return false };
|
||||
p = match marker {
|
||||
0xD9 => return true,
|
||||
0xFF => p.saturating_add(1),
|
||||
0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2),
|
||||
_ => {
|
||||
let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else {
|
||||
return false;
|
||||
};
|
||||
if len < 2 {
|
||||
return false;
|
||||
}
|
||||
p.saturating_add(2).saturating_add(len)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option<crate::NamedSpace>) -> Result<Decoded> {
|
||||
let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?;
|
||||
if m.components == 0 {
|
||||
@@ -175,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
|
||||
}
|
||||
|
||||
/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this
|
||||
/// is most of [`header`]'s time).
|
||||
fn find_ff(b: &[u8]) -> Option<usize> {
|
||||
const ONES: u64 = 0x0101_0101_0101_0101;
|
||||
let (words, tail) = b.as_chunks::<8>();
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
// a zero byte in !w is an 0xFF byte in w
|
||||
let x = !u64::from_ne_bytes(*w);
|
||||
if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 {
|
||||
return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j);
|
||||
}
|
||||
}
|
||||
tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j)
|
||||
}
|
||||
|
||||
/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once
|
||||
/// fitted with the source aspect ratio.
|
||||
fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) {
|
||||
@@ -297,6 +363,20 @@ mod tests {
|
||||
assert_eq!(scaled_request(100, 100, 256, 256), (100, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ff_matches_a_plain_search() {
|
||||
let mut b: Vec<u8> = (0..300u32).map(|i| (i * 7 % 255) as u8).collect();
|
||||
for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] {
|
||||
if let Some(i) = at {
|
||||
b[i] = 0xFF;
|
||||
}
|
||||
for start in [0, 1, 5, 9, 290, 299, 300] {
|
||||
let s = &b[start..];
|
||||
assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_on_garbage() {
|
||||
assert!(parse_markers(b"").is_none());
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are
|
||||
//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied.
|
||||
//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space).
|
||||
//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without
|
||||
//! decoding pixels (import probes).
|
||||
//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode).
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
@@ -156,6 +158,52 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result<Decoded> {
|
||||
std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into())))
|
||||
}
|
||||
|
||||
/// What a file's headers say about its image: enough to catalogue it without decoding pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub format: Format,
|
||||
/// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]).
|
||||
pub orientation: u16,
|
||||
}
|
||||
|
||||
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
|
||||
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
|
||||
/// decode.
|
||||
///
|
||||
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
|
||||
/// unsupported layout, compression or frame type) and files whose image data is cut short
|
||||
/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its
|
||||
/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a
|
||||
/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a
|
||||
/// third-party parser is caught on unwinding targets).
|
||||
pub fn read_header(bytes: &[u8]) -> Result<Header> {
|
||||
let format = sniff(bytes).ok_or(Error::UnknownFormat)?;
|
||||
std::panic::catch_unwind(|| read_header_unguarded(bytes, format))
|
||||
.unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into())))
|
||||
}
|
||||
|
||||
/// [`read_header`] without the panic guard (for fuzzing our own code paths).
|
||||
#[doc(hidden)]
|
||||
pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
|
||||
let (width, height, orientation) = match format {
|
||||
Format::Jpeg => jpeg::header(bytes)?,
|
||||
Format::Png => png_codec::header(bytes)?,
|
||||
Format::Tiff => tiff_codec::header(bytes)?,
|
||||
Format::WebP => webp::header(bytes)?,
|
||||
Format::Psd => psd::header(bytes)?,
|
||||
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
|
||||
_ => {
|
||||
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
|
||||
(d.source_width, d.source_height, d.orientation)
|
||||
}
|
||||
};
|
||||
Ok(Header { format, width, height, orientation })
|
||||
}
|
||||
|
||||
/// Decode a JPEG preview with its enclosing container's colour space as a fallback.
|
||||
/// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied
|
||||
/// before transfer decoding and linear-light resizing, never to already-resampled pixels.
|
||||
|
||||
@@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without
|
||||
/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed
|
||||
/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT
|
||||
/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked.
|
||||
pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
if !b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
|
||||
if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") {
|
||||
return Err(err("missing IHDR"));
|
||||
}
|
||||
let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?;
|
||||
let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]]));
|
||||
let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]);
|
||||
if w > i32::MAX as u32 || h > i32::MAX as u32 {
|
||||
return Err(err("dimensions out of range"));
|
||||
}
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let depth_ok = match color {
|
||||
0 => matches!(depth, 1 | 2 | 4 | 8 | 16),
|
||||
3 => matches!(depth, 1 | 2 | 4 | 8),
|
||||
2 | 4 | 6 => matches!(depth, 8 | 16),
|
||||
_ => false,
|
||||
};
|
||||
if !depth_ok || compression != 0 || filter != 0 || interlace > 1 {
|
||||
return Err(err("invalid IHDR"));
|
||||
}
|
||||
let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None);
|
||||
while let Some(len) = be32(p) {
|
||||
let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break };
|
||||
if idat && kind != b"IDAT" {
|
||||
// a chunk follows the image data: the IDAT run is complete
|
||||
after_idat = true;
|
||||
}
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
let start = p.saturating_add(8);
|
||||
let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten();
|
||||
let Some(data) = data else {
|
||||
if after_idat {
|
||||
break; // a damaged chunk after the image data: a decode ignores it too
|
||||
}
|
||||
return Err(err("truncated"));
|
||||
};
|
||||
match kind {
|
||||
b"PLTE" => palette = true,
|
||||
b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image
|
||||
b"IDAT" => {
|
||||
if color == 3 && !palette {
|
||||
return Err(err("indexed image without a palette"));
|
||||
}
|
||||
idat = true;
|
||||
}
|
||||
b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)),
|
||||
_ => {}
|
||||
}
|
||||
p = start.saturating_add(data.len()).saturating_add(4);
|
||||
}
|
||||
if !idat {
|
||||
return Err(err("no image data"));
|
||||
}
|
||||
if !after_idat {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
if let Some(c) = &info.coding_independent_code_points {
|
||||
let named = match c.color_primaries {
|
||||
|
||||
+265
-72
@@ -69,8 +69,8 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let (icc, exif, xmp) = resources(res);
|
||||
|
||||
// Layer and mask information: only the layer count sign matters (merged transparency).
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
let lm = r.take(lm_len)?;
|
||||
let lm_len = if psb { r.u64()? } else { u64::from(r.u32()?) };
|
||||
let lm = r.take(usize::try_from(lm_len).map_err(|_| err("truncated"))?)?;
|
||||
let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } {
|
||||
let off = if psb { 8 } else { 4 };
|
||||
let count = i16::from_be_bytes([lm[off], lm[off + 1]]);
|
||||
@@ -88,33 +88,6 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let plane = width * height * bpc;
|
||||
let row_bytes = width * bpc;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(channels);
|
||||
match compression {
|
||||
0 => {
|
||||
for _ in 0..channels {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let counts_len = channels * height;
|
||||
let mut counts = Vec::with_capacity(counts_len);
|
||||
for _ in 0..counts_len {
|
||||
counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
for c in 0..channels {
|
||||
let mut out = Vec::with_capacity(plane);
|
||||
for y in 0..height {
|
||||
let src = r.take(counts[c * height + y])?;
|
||||
unpackbits(src, row_bytes, &mut out);
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
|
||||
let (model, n_color) = match mode {
|
||||
1 | 8 => (Model::Gray, 1),
|
||||
2 => (Model::Rgb, 1),
|
||||
@@ -128,50 +101,106 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
let alpha = merged_transparency && channels > n_color && mode != 2;
|
||||
// Only the colour (+ alpha) planes are decoded; extra channels (spot colours, masks) are
|
||||
// stored after them and never touched.
|
||||
let used = n_color + alpha as usize;
|
||||
let n = width * height;
|
||||
let too_large = || Error::TooLarge(width as u64, height as u64);
|
||||
let n = width.checked_mul(height).ok_or_else(too_large)?;
|
||||
let row_bytes = width.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let plane = n.checked_mul(bpc).ok_or_else(too_large)?;
|
||||
let mut planes: Vec<Vec<u8>> = Vec::with_capacity(used);
|
||||
match compression {
|
||||
0 => {
|
||||
// Raw planes are borrowed from the input, so their size is bounded by the file.
|
||||
for _ in 0..used {
|
||||
planes.push(r.take(plane)?.to_vec());
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Row byte-count table: one entry per row per channel (all channels, since the data
|
||||
// follows it). Taken from the input before anything is allocated from it.
|
||||
let entry = if psb { 4 } else { 2 };
|
||||
let table_len = channels.checked_mul(height).and_then(|c| c.checked_mul(entry)).ok_or_else(|| err("truncated"))?;
|
||||
let table = r.take(table_len)?;
|
||||
let count = |i: usize| -> usize {
|
||||
let at = i.saturating_mul(entry);
|
||||
match table.get(at..at.saturating_add(entry)) {
|
||||
Some(&[a, b]) => usize::from(u16::from_be_bytes([a, b])),
|
||||
Some(&[a, b, c, d]) => u32::from_be_bytes([a, b, c, d]) as usize,
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
// PackBits expands at most 64× (a 2-byte repeat run yields 128 bytes), so a row needs
|
||||
// at least this many encoded bytes. Requiring it (and the data being present) before
|
||||
// allocating bounds the decoded planes by the file size: a tiny file can no longer
|
||||
// declare gigabytes of zero-padded or repeat-run rows.
|
||||
let min_row = row_bytes.div_ceil(128).saturating_mul(2);
|
||||
let mut data_len = 0usize;
|
||||
for i in 0..used.saturating_mul(height) {
|
||||
let c = count(i);
|
||||
if c < min_row {
|
||||
return Err(err("RLE row too short for its width"));
|
||||
}
|
||||
data_len = data_len.saturating_add(c);
|
||||
}
|
||||
if data_len > bytes.len().saturating_sub(r.p) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
for c in 0..used {
|
||||
let mut out = zeroed::<u8>(plane, too_large)?;
|
||||
for (y, row) in out.chunks_exact_mut(row_bytes).enumerate() {
|
||||
let src = r.take(count(c * height + y))?;
|
||||
unpackbits(src, row)?;
|
||||
}
|
||||
planes.push(out);
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
}
|
||||
let samples = n.checked_mul(used).ok_or_else(too_large)?;
|
||||
|
||||
// Interleave to device samples.
|
||||
let buf = if mode == 2 {
|
||||
// Indexed → 8-bit RGB via the 768-byte palette.
|
||||
if color_mode_data.len() < 768 || depth != 8 {
|
||||
let (Some(pal), true) = (color_mode_data.get(..768), depth == 8) else {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let mut v = Vec::with_capacity(n * 3);
|
||||
for &i in &planes[0] {
|
||||
};
|
||||
let idx = planes.first().ok_or_else(|| err("missing plane"))?;
|
||||
let mut v = zeroed::<u8>(n.checked_mul(3).ok_or_else(too_large)?, too_large)?;
|
||||
for (o, &i) in v.chunks_exact_mut(3).zip(idx) {
|
||||
let i = i as usize;
|
||||
v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]);
|
||||
o.copy_from_slice(&[pal[i], pal[256 + i], pal[512 + i]]);
|
||||
}
|
||||
Buf::U8(v)
|
||||
} else {
|
||||
let cmyk = model == Model::Cmyk;
|
||||
match bpc {
|
||||
1 => {
|
||||
let mut v = vec![0u8; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = p[i];
|
||||
let mut v = zeroed::<u8>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, &x) in v.iter_mut().skip(c).step_by(used).zip(p) {
|
||||
// PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink.
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x };
|
||||
*o = if cmyk && c < 4 { 255 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U8(v)
|
||||
}
|
||||
2 => {
|
||||
let mut v = vec![0u16; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x };
|
||||
let mut v = zeroed::<u16>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(2)) {
|
||||
let x = u16::from_be_bytes([s[0], s[1]]);
|
||||
*o = if cmyk && c < 4 { 65535 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::U16(v)
|
||||
}
|
||||
_ => {
|
||||
let mut v = vec![0f32; n * used];
|
||||
for (c, p) in planes.iter().take(used).enumerate() {
|
||||
for i in 0..n {
|
||||
let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]);
|
||||
v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x };
|
||||
let mut v = zeroed::<f32>(samples, too_large)?;
|
||||
for (c, p) in planes.iter().enumerate() {
|
||||
for (o, s) in v.iter_mut().skip(c).step_by(used).zip(p.chunks_exact(4)) {
|
||||
let x = f32::from_be_bytes([s[0], s[1], s[2], s[3]]);
|
||||
*o = if cmyk && c < 4 { 1.0 - x } else { x };
|
||||
}
|
||||
}
|
||||
Buf::F32(v)
|
||||
@@ -182,25 +211,111 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts)
|
||||
}
|
||||
|
||||
/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short).
|
||||
fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec<u8>) {
|
||||
let target = out.len() + row_bytes;
|
||||
while out.len() < target && !src.is_empty() {
|
||||
let h = src[0] as i8;
|
||||
src = &src[1..];
|
||||
if h >= 0 {
|
||||
let n = (h as usize + 1).min(src.len()).min(target - out.len());
|
||||
out.extend_from_slice(&src[..n]);
|
||||
src = &src[(h as usize + 1).min(src.len())..];
|
||||
} else if h != -128 {
|
||||
let n = ((1 - h as isize) as usize).min(target - out.len());
|
||||
if let Some(&b) = src.first() {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
src = &src[1..];
|
||||
/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources,
|
||||
/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported
|
||||
/// depth, colour mode or compression, or merged image data that runs past the end of the file
|
||||
/// (truncated). The run-length data itself is not unpacked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut r = Rd { b: bytes, p: 0 };
|
||||
if r.take(4)? != b"8BPS" {
|
||||
return Err(err("bad signature"));
|
||||
}
|
||||
let psb = match r.u16()? {
|
||||
1 => false,
|
||||
2 => true,
|
||||
_ => return Err(err("bad version")),
|
||||
};
|
||||
r.take(6)?;
|
||||
let channels = r.u16()? as usize;
|
||||
let height = r.u32()? as usize;
|
||||
let width = r.u32()? as usize;
|
||||
let depth = r.u16()?;
|
||||
let mode = r.u16()?;
|
||||
check_size(F, width as u64, height as u64, &DecodeOptions::default())?;
|
||||
if channels == 0 || channels > 56 {
|
||||
return Err(err("bad channel count"));
|
||||
}
|
||||
let cm_len = r.u32()? as usize;
|
||||
let color_mode_data = r.take(cm_len)?;
|
||||
let res_len = r.u32()? as usize;
|
||||
let (_, exif, _) = resources(r.take(res_len)?);
|
||||
let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize };
|
||||
r.take(lm_len)?;
|
||||
let compression = r.u16()?;
|
||||
let bpc = match depth {
|
||||
8 => 1usize,
|
||||
16 => 2,
|
||||
32 => 4,
|
||||
1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")),
|
||||
_ => return Err(err("bad depth")),
|
||||
};
|
||||
let n_color = match mode {
|
||||
1 | 8 | 7 | 2 => 1,
|
||||
3 => 3,
|
||||
4 => 4,
|
||||
9 => return Err(Error::Unsupported(F, "Lab PSD")),
|
||||
_ => return Err(err("unknown colour mode")),
|
||||
};
|
||||
if channels < n_color {
|
||||
return Err(err("too few channels"));
|
||||
}
|
||||
if mode == 2 && (color_mode_data.len() < 768 || depth != 8) {
|
||||
return Err(err("bad indexed palette"));
|
||||
}
|
||||
let rest = bytes.len().saturating_sub(r.p);
|
||||
let need = match compression {
|
||||
0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)),
|
||||
1 => {
|
||||
// the row byte counts, then the rows they count
|
||||
let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?;
|
||||
let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?;
|
||||
let mut total = table;
|
||||
for _ in 0..rows {
|
||||
total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize });
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
_ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")),
|
||||
};
|
||||
if need.is_none_or(|n| n > rest) {
|
||||
return Err(err("truncated"));
|
||||
}
|
||||
let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1);
|
||||
Ok((width as u32, height as u32, orientation))
|
||||
}
|
||||
|
||||
/// A zero-filled buffer whose allocation failure is an error, not an abort.
|
||||
fn zeroed<T: Clone + Default>(len: usize, too_large: impl Fn() -> Error) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| too_large())?;
|
||||
v.resize(len, T::default());
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// PackBits: decode one row into `row` (pre-zeroed, so short run data leaves zero padding).
|
||||
/// A run that would write past the end of the row is an error; trailing bytes are ignored.
|
||||
fn unpackbits(mut src: &[u8], row: &mut [u8]) -> Result<()> {
|
||||
let mut at = 0;
|
||||
while at < row.len() {
|
||||
let Some((&h, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
let h = h as i8;
|
||||
if h >= 0 {
|
||||
let n = h as usize + 1;
|
||||
let lit = src.get(..n).unwrap_or(src);
|
||||
let dst = row.get_mut(at..at + lit.len()).ok_or_else(|| err("RLE row overruns its width"))?;
|
||||
dst.copy_from_slice(lit);
|
||||
at += lit.len();
|
||||
src = src.get(n..).unwrap_or(&[]);
|
||||
} else if h != -128 {
|
||||
let n = (1 - h as isize) as usize;
|
||||
let Some((&b, rest)) = src.split_first() else { break };
|
||||
src = rest;
|
||||
row.get_mut(at..at + n).ok_or_else(|| err("RLE row overruns its width"))?.fill(b);
|
||||
at += n;
|
||||
}
|
||||
}
|
||||
out.resize(target, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resources(mut b: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
|
||||
@@ -234,11 +349,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn packbits() {
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out);
|
||||
assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
let mut out = Vec::new();
|
||||
unpackbits(&[0x05, 1], 4, &mut out);
|
||||
assert_eq!(out, vec![1, 0, 0, 0]);
|
||||
let mut out = [0u8; 6];
|
||||
unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], &mut out).unwrap();
|
||||
assert_eq!(out, [0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]);
|
||||
// Short run data is zero-padded.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x01, 1], &mut out).unwrap();
|
||||
assert_eq!(out, [1, 0, 0, 0]);
|
||||
// Truncated literal run: copies what is there.
|
||||
let mut out = [0u8; 4];
|
||||
unpackbits(&[0x03, 7, 8], &mut out).unwrap();
|
||||
assert_eq!(out, [7, 8, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packbits_overrun_is_an_error() {
|
||||
// Repeat run of 128 into a 4-byte row.
|
||||
let mut out = [0u8; 4];
|
||||
assert!(unpackbits(&[0x81, 0xAA], &mut out).is_err());
|
||||
// Literal run of 6 into a 4-byte row.
|
||||
assert!(unpackbits(&[0x05, 1, 2, 3, 4, 5, 6], &mut out).is_err());
|
||||
}
|
||||
|
||||
/// PSD header + empty colour-mode/resources/layer sections + compression id.
|
||||
fn header(channels: u16, w: u32, h: u32, depth: u16, mode: u16, compression: u16) -> Vec<u8> {
|
||||
let mut b = b"8BPS".to_vec();
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&channels.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&depth.to_be_bytes());
|
||||
b.extend_from_slice(&mode.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 12]); // colour mode data, resources, layer & mask: all empty
|
||||
b.extend_from_slice(&compression.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_with_truncated_count_table_is_rejected_before_allocating() {
|
||||
// 1 × 2^30 px, 56 channels: the row table alone would be 56 Gi entries; the file has 4 bytes.
|
||||
let mut b = header(56, 1, 1 << 30, 32, 3, 1);
|
||||
b.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_rle_of_empty_rows_is_rejected_before_allocating() {
|
||||
// 2^30 × 1 px RGB at 32 bits: every row count is 0, which used to zero-pad 3 × 4 GiB.
|
||||
let mut b = header(3, 1 << 30, 1, 32, 3, 1);
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("too short")), "{e:?}");
|
||||
// 1 Gpx RGB 8-bit whose (plausible) row counts claim more data than the file holds.
|
||||
let mut b = header(3, 1 << 20, 1 << 10, 8, 3, 1);
|
||||
b.extend_from_slice(&[0xFF; 3 * 1024 * 2]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m == "truncated"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_repeat_run_past_row_width_is_an_error() {
|
||||
// 4 × 1 gray 8-bit; the single row is a 128-byte repeat run.
|
||||
let mut b = header(1, 4, 1, 8, 1, 1);
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0x81, 0x55]);
|
||||
let e = decode(&b, &DecodeOptions::default()).unwrap_err();
|
||||
assert!(matches!(e, Error::Malformed(_, ref m) if m.contains("overruns")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_gray16_and_extra_channels_decode() {
|
||||
// 2 × 2 gray 16-bit with an extra (unused) channel whose data is absent: only the used
|
||||
// plane is decoded. Row: repeat run of 4 bytes 0x80 -> samples 0x8080.
|
||||
let mut b = header(2, 2, 2, 16, 1, 1);
|
||||
for _ in 0..4 {
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0xFD, 0x80, 0xFD, 0x80]);
|
||||
let d = decode(&b, &DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (2, 2));
|
||||
let p = d.image.get(1, 1);
|
||||
let q = d.image.get(0, 0);
|
||||
assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error {
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits);
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, opts)?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
@@ -31,23 +28,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok();
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
|
||||
let (model, n_color, n_samples, bits) = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if bits < 8 && planar && n_samples > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?;
|
||||
let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_));
|
||||
let premultiplied = has_alpha && extra.first() == Some(&1);
|
||||
|
||||
@@ -111,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
finish(F, raw, meta, (w, h), opts)
|
||||
}
|
||||
|
||||
fn limits(opts: &DecodeOptions) -> Limits {
|
||||
let mut limits = Limits::default();
|
||||
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
|
||||
limits.ifd_value_size = 32 << 20;
|
||||
limits
|
||||
}
|
||||
|
||||
/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel,
|
||||
/// bits per sample).
|
||||
fn layout(ct: ColorType, compression: Option<u32>, planar: bool) -> Result<(Model, usize, usize, u8)> {
|
||||
let l = match ct {
|
||||
ColorType::Gray(b) => (Model::Gray, 1, 1, b),
|
||||
ColorType::GrayA(b) => (Model::Gray, 1, 2, b),
|
||||
ColorType::RGB(b) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::RGBA(b) => (Model::Rgb, 3, 4, b),
|
||||
ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b),
|
||||
ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b),
|
||||
ColorType::Palette(b) => (Model::Rgb, 1, 1, b),
|
||||
// JPEG-compressed YCbCr is converted to RGB by the JPEG decoder.
|
||||
ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth),
|
||||
ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth),
|
||||
other => return Err(Error::Unsupported(F, unsupported_name(other))),
|
||||
};
|
||||
if l.3 < 8 && planar && l.2 > 1 {
|
||||
return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte"));
|
||||
}
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused,
|
||||
/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that
|
||||
/// start past the end of the file (truncated). The compressed data itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default()));
|
||||
let (w, h) = d.dimensions().map_err(err)?;
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let ct = d.colortype().map_err(err)?;
|
||||
let compression = d.get_tag_u32(Tag::Compression).ok();
|
||||
let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2);
|
||||
layout(ct, compression, planar)?;
|
||||
// what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits
|
||||
if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) {
|
||||
return Err(Error::Unsupported(F, "compression method"));
|
||||
}
|
||||
let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder<std::io::Cursor<&[u8]>>| {
|
||||
Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?))
|
||||
};
|
||||
let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d)
|
||||
.or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d))
|
||||
.ok_or_else(|| err("no image data"))?;
|
||||
// Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate
|
||||
// decodes such files (it stops once it has the pixels), so a chunk's end proves nothing.
|
||||
let len = bytes.len() as u64;
|
||||
if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) {
|
||||
return Err(err("truncated: image data past the end of the file"));
|
||||
}
|
||||
let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1);
|
||||
Ok((w, h, orientation))
|
||||
}
|
||||
|
||||
/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values).
|
||||
fn tag_bytes<R: std::io::Read + std::io::Seek>(d: &mut Decoder<R>, tag: Tag) -> Option<Vec<u8>> {
|
||||
use tiff::decoder::ifd::Value;
|
||||
|
||||
@@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 };
|
||||
finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts)
|
||||
}
|
||||
|
||||
/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without
|
||||
/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an
|
||||
/// animation frame), or one that runs past the end of the file (truncated). The compressed data
|
||||
/// itself is not checked.
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let mut p = 12usize;
|
||||
loop {
|
||||
let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) };
|
||||
let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize;
|
||||
let end = p.saturating_add(8).saturating_add(size);
|
||||
if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") {
|
||||
if end > bytes.len() {
|
||||
return Err(err("truncated image data"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
p = end.saturating_add(size & 1);
|
||||
}
|
||||
let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?;
|
||||
d.set_memory_limit(bytes.len());
|
||||
let (w, h) = d.dimensions();
|
||||
check_size(F, w as u64, h as u64, &DecodeOptions::default())?;
|
||||
let exif = d.exif_metadata().ok().flatten();
|
||||
let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e));
|
||||
Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1)))
|
||||
}
|
||||
@@ -30,8 +30,10 @@ fn try_all(bytes: &[u8]) {
|
||||
if let Some(f) = sniff(bytes) {
|
||||
let _ = decode_unguarded(bytes, f, &small_opts());
|
||||
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
|
||||
let _ = read_header_unguarded(bytes, f);
|
||||
}
|
||||
let _ = decode(bytes, small_opts());
|
||||
let _ = read_header(bytes);
|
||||
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
|
||||
let _ = lightcraft_codecs::icc::parse(bytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a
|
||||
//! decode refuses (truncated files included), and returns errors — never panics — on hostile input.
|
||||
|
||||
use lightcraft_codecs::exif::minimal_exif;
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_raster::Rgba8;
|
||||
|
||||
fn pixels(w: usize, h: usize) -> Rgba8 {
|
||||
Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8])
|
||||
}
|
||||
|
||||
fn meta(exif: &Option<Vec<u8>>) -> EncodeMeta<'_> {
|
||||
EncodeMeta { exif: exif.as_deref(), xmp: Some("<x/>"), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder.
|
||||
fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec<u8> {
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut cur = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = TiffEncoder::new(&mut cur).unwrap();
|
||||
let mut im = enc.new_image::<colortype::RGB8>(img.width as u32, img.height as u32).unwrap();
|
||||
im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap();
|
||||
im.write_data(&rgb).unwrap();
|
||||
cur.into_inner()
|
||||
}
|
||||
|
||||
/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource.
|
||||
fn psd(w: usize, h: usize, orientation: Option<u16>, rle: bool) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(b"8BPS");
|
||||
b.extend_from_slice(&1u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0; 6]);
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
b.extend_from_slice(&(w as u32).to_be_bytes());
|
||||
b.extend_from_slice(&8u16.to_be_bytes());
|
||||
b.extend_from_slice(&3u16.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||||
let mut res = Vec::new();
|
||||
if let Some(o) = orientation {
|
||||
let exif = minimal_exif(o);
|
||||
res.extend_from_slice(b"8BIM");
|
||||
res.extend_from_slice(&1058u16.to_be_bytes());
|
||||
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
|
||||
res.extend_from_slice(&(exif.len() as u32).to_be_bytes());
|
||||
res.extend_from_slice(&exif);
|
||||
if exif.len() % 2 == 1 {
|
||||
res.push(0);
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(&res);
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info
|
||||
b.extend_from_slice(&(rle as u16).to_be_bytes());
|
||||
let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8);
|
||||
if rle {
|
||||
// every row as literal runs of at most 128 bytes (PackBits' longest)
|
||||
let row_len = w + w.div_ceil(128);
|
||||
for _ in 0..3 * h {
|
||||
b.extend_from_slice(&(row_len as u16).to_be_bytes());
|
||||
}
|
||||
for c in 0..3 {
|
||||
let p: Vec<u8> = plane(c).collect();
|
||||
for row in p.chunks(w) {
|
||||
for run in row.chunks(128) {
|
||||
b.push(run.len() as u8 - 1);
|
||||
b.extend_from_slice(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for c in 0..3 {
|
||||
b.extend(plane(c));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`).
|
||||
fn fixtures(w: usize, h: usize) -> Vec<(String, Vec<u8>)> {
|
||||
let img = pixels(w, h);
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let rgb: Vec<u8> = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb));
|
||||
let u16s: Vec<u16> = (0..w * h * 3).map(|i| (i * 331) as u16).collect();
|
||||
let mut out = Vec::new();
|
||||
for o in [None, Some(1), Some(6), Some(8)] {
|
||||
let exif = o.map(minimal_exif);
|
||||
let tag = format!("o{}", o.unwrap_or(0));
|
||||
out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap()));
|
||||
out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap()));
|
||||
out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap()));
|
||||
out.push((format!("psd {tag}"), psd(w, h, o, false)));
|
||||
out.push((format!("psd rle {tag}"), psd(w, h, o, true)));
|
||||
out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1))));
|
||||
}
|
||||
out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap()));
|
||||
out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap()));
|
||||
let mut bmp = Vec::new();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap();
|
||||
out.push(("bmp".into(), bmp));
|
||||
out
|
||||
}
|
||||
|
||||
/// What the import probe learnt before it read headers: a small decode.
|
||||
fn decoded(bytes: &[u8]) -> Result<Header> {
|
||||
let d = decode(bytes, DecodeOptions::fit(64, 64))?;
|
||||
Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_matches_decode() {
|
||||
for (w, h) in [(160, 96), (96, 160), (7, 5)] {
|
||||
for (name, bytes) in fixtures(w, h) {
|
||||
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}"));
|
||||
assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}");
|
||||
assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}");
|
||||
}
|
||||
}
|
||||
let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap();
|
||||
assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1));
|
||||
}
|
||||
|
||||
/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture),
|
||||
/// the header agrees with it: reading headers never refuses a file that decodes. The reverse
|
||||
/// is checked for truncation, which the header readers detect.
|
||||
#[test]
|
||||
fn truncated_files_agree_with_decode() {
|
||||
// 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
let n = bytes.len();
|
||||
let mut cuts: Vec<usize> = (0..100).map(|i| n * i / 100).collect();
|
||||
cuts.extend(n.saturating_sub(64)..n);
|
||||
for cut in cuts {
|
||||
let part = &bytes[..cut];
|
||||
if let Ok(d) = decoded(part) {
|
||||
assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}");
|
||||
}
|
||||
}
|
||||
for cut in [n / 4, n / 2, n * 9 / 10] {
|
||||
assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_details() {
|
||||
let f = fixtures(160, 96);
|
||||
let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap();
|
||||
let jpeg = get("jpeg 420 o6");
|
||||
assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated");
|
||||
let mut trailing = jpeg.clone();
|
||||
trailing.extend_from_slice(&[0; 100]);
|
||||
assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine");
|
||||
let png = get("png rgba o6");
|
||||
assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode");
|
||||
assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated");
|
||||
}
|
||||
|
||||
/// eXIf after the image data counts, as it does for a decode (which reads the whole file).
|
||||
#[test]
|
||||
fn png_exif_after_idat() {
|
||||
let img = pixels(9, 4);
|
||||
let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
|
||||
let exif = minimal_exif(6);
|
||||
let mut chunk = (exif.len() as u32).to_be_bytes().to_vec();
|
||||
chunk.extend_from_slice(b"eXIf");
|
||||
chunk.extend_from_slice(&exif);
|
||||
chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader
|
||||
let iend = plain.len() - 12;
|
||||
let mut b = plain[..iend].to_vec();
|
||||
b.extend_from_slice(&chunk);
|
||||
b.extend_from_slice(&plain[iend..]);
|
||||
assert_eq!(read_header(&b).unwrap().orientation, 6);
|
||||
}
|
||||
|
||||
fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec<u8> {
|
||||
let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8];
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.push(comps);
|
||||
for c in 0..comps {
|
||||
b.extend_from_slice(&[c + 1, 0x11, 0]);
|
||||
}
|
||||
b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]);
|
||||
b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]);
|
||||
b.extend_from_slice(&[0xFF, 0xD9]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec<u8> {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
b.extend_from_slice(&w.to_be_bytes());
|
||||
b.extend_from_slice(&h.to_be_bytes());
|
||||
b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
fn png_chunk(b: &mut Vec<u8>, kind: &[u8; 4], len: u32, data: &[u8]) {
|
||||
b.extend_from_slice(&len.to_be_bytes());
|
||||
b.extend_from_slice(kind);
|
||||
b.extend_from_slice(data);
|
||||
b.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_are_errors() {
|
||||
// a well-formed synthetic JPEG header is read
|
||||
assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 });
|
||||
let jpeg_bad = [
|
||||
jpeg_segments(0xC0, 0, 21, 3), // zero width
|
||||
jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL)
|
||||
jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx
|
||||
jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding
|
||||
jpeg_segments(0xC5, 33, 21, 3), // hierarchical
|
||||
jpeg_segments(0xC0, 33, 21, 2), // two components
|
||||
jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(),
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0
|
||||
vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end
|
||||
vec![0xFF, 0xD8, 0xFF, 0xD9],
|
||||
];
|
||||
for (i, b) in jpeg_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "JPEG case {i}");
|
||||
}
|
||||
let mut no_sos = jpeg_segments(0xC0, 33, 21, 3);
|
||||
no_sos.truncate(2 + 2 + 17);
|
||||
no_sos.extend_from_slice(&[0xFF, 0xD9]);
|
||||
assert!(read_header(&no_sos).is_err(), "JPEG without a scan");
|
||||
|
||||
let mut ok = png_ihdr(5, 3, 8, 2);
|
||||
png_chunk(&mut ok, b"IDAT", 1, &[0]);
|
||||
png_chunk(&mut ok, b"IEND", 0, &[]);
|
||||
assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 });
|
||||
let png_case = |ihdr: Vec<u8>, chunks: &[(&[u8; 4], u32, &[u8])]| {
|
||||
let mut b = ihdr;
|
||||
for (k, l, d) in chunks {
|
||||
png_chunk(&mut b, k, *l, d);
|
||||
}
|
||||
b
|
||||
};
|
||||
let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]);
|
||||
let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]);
|
||||
let png_bad = [
|
||||
png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]),
|
||||
png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits
|
||||
png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5
|
||||
png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]),
|
||||
png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]),
|
||||
png_ihdr(5, 3, 8, 2)[..20].to_vec(),
|
||||
b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in png_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PNG case {i}");
|
||||
}
|
||||
|
||||
let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap();
|
||||
let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize;
|
||||
let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()];
|
||||
// cut short after the IFD
|
||||
assert!(ifd < tiff.len() / 2, "the IFD comes before the image data");
|
||||
tiff_bad.push(tiff[..tiff.len() - 10].to_vec());
|
||||
for (i, b) in tiff_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "TIFF case {i}");
|
||||
}
|
||||
// a strip offset pointing past the end of the file
|
||||
let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]);
|
||||
let n = t(&tiff, ifd) as usize;
|
||||
let mut far = tiff.clone();
|
||||
for e in 0..n {
|
||||
let p = ifd + 2 + e * 12;
|
||||
if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 {
|
||||
far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
assert_ne!(far, tiff, "the strip offset was found");
|
||||
assert!(read_header(&far).is_err(), "strip past the end");
|
||||
|
||||
let webp_bad = [
|
||||
b"RIFF\0\0\0\0WEBP".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(),
|
||||
b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(),
|
||||
];
|
||||
for (i, b) in webp_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "WebP case {i}");
|
||||
}
|
||||
|
||||
let good = psd(4, 3, Some(6), true);
|
||||
assert_eq!(read_header(&good).unwrap().orientation, 6);
|
||||
let set = |pos: usize, v: &[u8]| {
|
||||
let mut b = good.clone();
|
||||
b[pos..pos + v.len()].copy_from_slice(v);
|
||||
b
|
||||
};
|
||||
let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize;
|
||||
let compression = 34 + res_len + 4;
|
||||
let mut psd_bad = vec![
|
||||
set(12, &0u16.to_be_bytes()), // no channels
|
||||
set(12, &57u16.to_be_bytes()), // too many
|
||||
set(12, &2u16.to_be_bytes()), // too few for RGB
|
||||
set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height
|
||||
set(18, &0u32.to_be_bytes()), // zero width
|
||||
set(22, &1u16.to_be_bytes()), // 1-bit
|
||||
set(22, &7u16.to_be_bytes()), // bad depth
|
||||
set(24, &9u16.to_be_bytes()), // Lab
|
||||
set(24, &77u16.to_be_bytes()), // unknown mode
|
||||
set(24, &2u16.to_be_bytes()), // indexed without a palette
|
||||
set(compression, &2u16.to_be_bytes()), // ZIP
|
||||
set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end
|
||||
set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end
|
||||
];
|
||||
// a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts
|
||||
let mut tall = good.clone();
|
||||
tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes());
|
||||
tall[18..22].copy_from_slice(&1u32.to_be_bytes());
|
||||
psd_bad.push(tall);
|
||||
psd_bad.push(good[..good.len() - 1].to_vec());
|
||||
for (i, b) in psd_bad.iter().enumerate() {
|
||||
assert!(read_header(b).is_err(), "PSD case {i}");
|
||||
}
|
||||
|
||||
for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] {
|
||||
assert!(read_header(b).is_err(), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the
|
||||
/// header agrees with it (deterministic seed).
|
||||
#[test]
|
||||
fn mutated_files_agree_with_decode() {
|
||||
let mut s: u64 = 0x1234_5678_9abc_def1;
|
||||
let mut rnd = || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
s
|
||||
};
|
||||
let (mut decodable, mut disagreements) = (0, Vec::new());
|
||||
for (name, bytes) in fixtures(160, 96) {
|
||||
for _ in 0..300 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..1 + rnd() % 3 {
|
||||
let i = rnd() as usize % b.len();
|
||||
b[i] = rnd() as u8;
|
||||
}
|
||||
if let Ok(d) = decoded(&b) {
|
||||
decodable += 1;
|
||||
let h = read_header(&b);
|
||||
if h.as_ref().ok() != Some(&d) {
|
||||
disagreements.push(format!("{name}: decode {d:?}, header {h:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(decodable > 1000, "{decodable}");
|
||||
assert!(disagreements.is_empty(), "{disagreements:#?}");
|
||||
}
|
||||
@@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192;
|
||||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||||
/// same formats by file extension.
|
||||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3)
|
||||
matches!(
|
||||
format,
|
||||
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
@@ -1017,8 +1020,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn supported_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look));
|
||||
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
|
||||
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
// DNG carries its own colour model
|
||||
assert!(!file_local_look(RawFormat::Dng));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -92,8 +92,10 @@ fn ext_upper(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their
|
||||
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
|
||||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed:
|
||||
/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too
|
||||
/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside
|
||||
/// compressed data that is all there: such a file imports and shows as unreadable when rendered).
|
||||
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
|
||||
let m = lightcraft_meta::extract(bytes);
|
||||
@@ -152,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||||
if !fmt.can_decode() {
|
||||
return Err(format!("{fmt:?} files are not supported yet"));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
|
||||
let o = Orientation::from_exif(d.orientation);
|
||||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||||
let o = Orientation::from_exif(header.orientation);
|
||||
let (mut w, mut h) = (header.width, header.height);
|
||||
if o.swaps_axes() {
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
@@ -442,6 +445,37 @@ mod tests {
|
||||
use super::*;
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
|
||||
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
|
||||
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||||
/// file or one that isn't an image.
|
||||
#[test]
|
||||
fn image_probe_reads_headers_like_the_decode_did() {
|
||||
let (w, h) = (300u32, 200u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||||
for o in [None, Some(1), Some(6), Some(3), Some(8)] {
|
||||
let exif = o.map(lightcraft_codecs::exif::minimal_exif);
|
||||
let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap();
|
||||
let png = lightcraft_codecs::encode_png(&img, &meta).unwrap();
|
||||
for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] {
|
||||
let p = probe_bytes(name, bytes).unwrap();
|
||||
// what the probe computed from a decode before
|
||||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap();
|
||||
let swap = Orientation::from_exif(d.orientation).swaps_axes();
|
||||
let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) };
|
||||
assert_eq!((p.width, p.height), want, "{name} {o:?}");
|
||||
assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}");
|
||||
assert_eq!(p.kind, MediaKind::Image);
|
||||
assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" });
|
||||
assert_eq!(p.file_size, bytes.len() as u64);
|
||||
assert!(p.content_hash.is_some());
|
||||
assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted");
|
||||
}
|
||||
}
|
||||
assert!(probe_bytes("x.jpg", b"not an image").is_err());
|
||||
}
|
||||
|
||||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||||
/// mostly cancel, edges remain.
|
||||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 14;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 15;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
@@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
|
||||
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
use lightcraft_develop::{DevelopSettings, WbMode};
|
||||
for format in ["ARW", "NEF", "NRW", "RAF"] {
|
||||
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
|
||||
let mut s = demo();
|
||||
let id = PhotoId(100);
|
||||
let name = format!("synthetic.{format}");
|
||||
|
||||
Vendored
+59
-3
@@ -103,21 +103,33 @@ pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result<Vec<u16>>
|
||||
output.resize(count, 0);
|
||||
}
|
||||
for (i, &value) in decoded.iter().enumerate() {
|
||||
let sensor_value =
|
||||
value.checked_add(midpoint).filter(|&v| v >= 0 && v <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
|
||||
let sensor_value = sensor_sample(value, midpoint, maximum, config.levels != 0)?;
|
||||
let (x, y) = (tile.x + 2 * (i % (tile.width / 2)) + p % 2, tile.y + 2 * (i / (tile.width / 2)) + p / 2);
|
||||
let slot = y
|
||||
.checked_mul(width)
|
||||
.and_then(|n| n.checked_add(x))
|
||||
.and_then(|n| output.get_mut(n))
|
||||
.ok_or_else(|| corrupt("tile writes outside image"))?;
|
||||
*slot = sensor_value as u16;
|
||||
*slot = sensor_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// A decoded plane value as a sensor sample of the CMP1 bit depth.
|
||||
///
|
||||
/// Lossless planes reproduce the sensor exactly, so a value outside the bit depth is corrupt data.
|
||||
/// Wavelet (C-RAW) planes are quantized: next to clipped highlights the reconstruction overshoots
|
||||
/// the sensor's range by up to a few quantization steps (measured on 17 CC0 files: at most 36 codes
|
||||
/// above 16383 at 14 bits, never below 0), while lossless files of the same bodies clip at exactly
|
||||
/// 16383. Those samples are clamped to the range the sensor can record.
|
||||
fn sensor_sample(value: i32, midpoint: i32, maximum: i32, quantized: bool) -> Result<u16> {
|
||||
let sample = value.checked_add(midpoint).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?;
|
||||
let sample = if quantized { sample.clamp(0, maximum) } else { sample };
|
||||
u16::try_from(sample).ok().filter(|_| sample <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))
|
||||
}
|
||||
|
||||
/// Header-only probing must reject the same unsupported coding variants as full decoding.
|
||||
pub(crate) fn validate(sample: &[u8], config: &Cr3Compression) -> Result<()> {
|
||||
supports(config)?;
|
||||
@@ -913,6 +925,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() {
|
||||
// As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200
|
||||
// (Rice escape: 41 zeros, 1, 21-bit literal 16400 = signed +8200). Flat synthesis gives
|
||||
// 8192 + 8200 = 16392, 9 codes above the 14-bit maximum, as in clipped C-RAW highlights.
|
||||
let mut config = coding();
|
||||
config.width = 16;
|
||||
config.height = 16;
|
||||
config.tile_width = 16;
|
||||
config.tile_height = 16;
|
||||
config.header_size = 540;
|
||||
config.levels = 3;
|
||||
let mut sample = Vec::new();
|
||||
marker(&mut sample, 0xff01, 80, 0);
|
||||
for plane in 0..4 {
|
||||
marker(&mut sample, 0xff02, 20, (plane << 28) | 0x0800_0000);
|
||||
for band in 0..10 {
|
||||
marker(&mut sample, 0xff03, [8, 1, 1, 1, 1, 1, 1, 2, 2, 2][band as usize], (band << 28) | 0x0020_0000);
|
||||
}
|
||||
}
|
||||
for _ in 0..4 {
|
||||
sample.extend([0, 0, 0, 0, 0, 0x40, 0x80, 0x20]); // LL3 = +8200.
|
||||
sample.extend([0x80; 3]); // The three 1×1 HF3 bands.
|
||||
sample.extend([0xf0; 3]); // Three 2×2 all-zero HF2 bands.
|
||||
for _ in 0..3 {
|
||||
sample.extend([0xff, 0xf8]); // Three 4×4 all-zero HF1 bands.
|
||||
}
|
||||
}
|
||||
validate(&sample, &config).unwrap();
|
||||
assert_eq!(decode(&sample, &config).unwrap(), vec![16383; 256]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_quantized_planes_clamp_out_of_range_samples() {
|
||||
assert_eq!(sensor_sample(8200, 8192, 16383, true).unwrap(), 16383);
|
||||
assert_eq!(sensor_sample(-8300, 8192, 16383, true).unwrap(), 0);
|
||||
assert_eq!(sensor_sample(8191, 8192, 16383, false).unwrap(), 16383);
|
||||
assert_eq!(sensor_sample(-8192, 8192, 16383, false).unwrap(), 0);
|
||||
// Lossless planes reproduce the sensor exactly: outside the bit depth is corrupt data.
|
||||
assert!(matches!(sensor_sample(8192, 8192, 16383, false), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(sensor_sample(-8193, 8192, 16383, false), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(sensor_sample(i32::MAX, 8192, 16383, true), Err(RawError::Corrupt(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_quantization_and_allocation_limits() {
|
||||
for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] {
|
||||
|
||||
@@ -1287,6 +1287,8 @@
|
||||
"Scanning": "Wird durchsucht",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Die Werte bleiben an den Fotos gespeichert: Filtere danach oder erstelle Smart-Alben mit Schärfe und „Beste ähnlicher Aufnahmen“.",
|
||||
"Screen": "Bildschirm",
|
||||
"Scroll menu down": "Menü nach unten scrollen",
|
||||
"Scroll menu up": "Menü nach oben scrollen",
|
||||
"Search": "Suche",
|
||||
"Search Photos": "Fotos suchen",
|
||||
"Search commands or keys": "Befehle oder Tasten suchen",
|
||||
|
||||
@@ -1235,6 +1235,8 @@
|
||||
"Scanning": "Explorando",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Las puntuaciones se quedan en las fotos: se pueden filtrar o crear álbumes inteligentes con Enfoque y La mejor de las tomas similares.",
|
||||
"Screen": "Pantalla",
|
||||
"Scroll menu down": "Desplazar el menú hacia abajo",
|
||||
"Scroll menu up": "Desplazar el menú hacia arriba",
|
||||
"Search": "Buscar",
|
||||
"Search Photos": "Buscar fotos",
|
||||
"Search commands or keys": "Buscar comandos o teclas",
|
||||
|
||||
@@ -1172,6 +1172,8 @@
|
||||
"Scanning": "検索中",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "評価結果は写真に保存されます。ピント評価や類似写真内のベストショットで絞り込み・スマートアルバム作成ができます",
|
||||
"Screen": "画面用",
|
||||
"Scroll menu down": "メニューを下にスクロール",
|
||||
"Scroll menu up": "メニューを上にスクロール",
|
||||
"Search": "検索",
|
||||
"Search Photos": "写真を検索",
|
||||
"Search commands or keys": "コマンドまたはキーを検索",
|
||||
|
||||
@@ -1245,6 +1245,8 @@
|
||||
"Scanning": "Examinando",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "As pontuações ficam nas fotos: filtre ou crie álbuns inteligentes com Foco e Melhor de fotos semelhantes.",
|
||||
"Screen": "Tela",
|
||||
"Scroll menu down": "Rolar o menu para baixo",
|
||||
"Scroll menu up": "Rolar o menu para cima",
|
||||
"Search": "Pesquisar",
|
||||
"Search Photos": "Pesquisar fotos",
|
||||
"Search commands or keys": "Pesquisar comandos ou teclas",
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
"Color Noise Reduction": "Подавление цветового шума",
|
||||
"Color Priority": "Приоритет цвета",
|
||||
"Color Range": "Цветовой диапазон",
|
||||
"Color label cleared": "Цветовая метка снята",
|
||||
"Color space": "Цветовое пространство",
|
||||
"Color space: sRGB (AVIF)": "Цветовое пространство: sRGB (AVIF)",
|
||||
"Combine files…": "Объединить файлы…",
|
||||
@@ -1027,6 +1028,8 @@
|
||||
"Scanning": "Сканирование",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.",
|
||||
"Screen": "Экран",
|
||||
"Scroll menu down": "Прокрутить меню вниз",
|
||||
"Scroll menu up": "Прокрутить меню вверх",
|
||||
"Search Photos": "Поиск фото",
|
||||
"Search commands or keys": "Поиск команд или клавиш",
|
||||
"Search…": "Поиск…",
|
||||
@@ -1116,6 +1119,7 @@
|
||||
"Skip": "Пропустить",
|
||||
"Sky": "Небо",
|
||||
"Slideshow": "Слайд-шоу",
|
||||
"Slideshow · Space pauses · Esc ends": "Слайд-шоу · Пробел — пауза · Esc — выход",
|
||||
"Smart Album": "Смарт-альбом",
|
||||
"Smart Album Rule Fields": "Поля условий смарт-альбома",
|
||||
"Smart Preview Status": "Состояние смарт-просмотров",
|
||||
|
||||
@@ -1233,6 +1233,8 @@
|
||||
"Scanning": "正在扫描",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。",
|
||||
"Screen": "屏幕",
|
||||
"Scroll menu down": "向下滚动菜单",
|
||||
"Scroll menu up": "向上滚动菜单",
|
||||
"Search": "搜索",
|
||||
"Search Photos": "搜索照片",
|
||||
"Search commands or keys": "搜索命令或按键",
|
||||
|
||||
@@ -1233,6 +1233,8 @@
|
||||
"Scanning": "正在掃描",
|
||||
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
|
||||
"Screen": "螢幕",
|
||||
"Scroll menu down": "向下捲動選單",
|
||||
"Scroll menu up": "向上捲動選單",
|
||||
"Search": "搜尋",
|
||||
"Search Photos": "搜尋照片",
|
||||
"Search commands or keys": "搜尋命令或按鍵",
|
||||
|
||||
@@ -103,6 +103,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
||||
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
|
||||
"import": app.import.as_ref().map(crate::import::ImportTask::status),
|
||||
"tasks": app.tasks.labels(),
|
||||
// commands waiting on a native file dialog shown off the UI thread (`pick`)
|
||||
"fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::<Vec<_>>(),
|
||||
"memory": memory(app),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ pub struct Headless {
|
||||
events: Vec<egui::Event>,
|
||||
control: Sender<ControlRequest>,
|
||||
quit: bool,
|
||||
/// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host).
|
||||
pub window_maximized: bool,
|
||||
/// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first.
|
||||
pub window_commands: Vec<ViewportCommand>,
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
@@ -133,6 +137,8 @@ impl Headless {
|
||||
events: vec![],
|
||||
control: tx,
|
||||
quit: false,
|
||||
window_maximized: false,
|
||||
window_commands: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +154,7 @@ impl Headless {
|
||||
/// Run one frame.
|
||||
pub fn step(&mut self) {
|
||||
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
|
||||
raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized);
|
||||
self.app.raw_input_hook(&mut raw);
|
||||
let app = &mut self.app;
|
||||
let commands = self.view.run(raw, |ui| {
|
||||
@@ -164,6 +171,11 @@ impl Headless {
|
||||
}
|
||||
ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s,
|
||||
ViewportCommand::Close => self.quit = true,
|
||||
ViewportCommand::Maximized(on) => {
|
||||
self.window_maximized = on;
|
||||
self.window_commands.push(c);
|
||||
}
|
||||
ViewportCommand::StartDrag => self.window_commands.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,19 @@ pub mod icons;
|
||||
pub mod import;
|
||||
pub mod lightroom_import;
|
||||
pub mod links;
|
||||
pub mod menu_level;
|
||||
pub mod menubar;
|
||||
pub mod menus;
|
||||
pub mod merge;
|
||||
pub mod panels;
|
||||
pub mod pick;
|
||||
pub mod render;
|
||||
pub mod shortcuts;
|
||||
pub mod softpaint;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod widgets;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -44,6 +47,8 @@ mod tests_masking;
|
||||
#[cfg(test)]
|
||||
mod tests_masking_layout;
|
||||
#[cfg(test)]
|
||||
mod tests_menubar;
|
||||
#[cfg(test)]
|
||||
mod tests_offline;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
@@ -54,6 +59,8 @@ mod tests_scroll;
|
||||
#[cfg(test)]
|
||||
mod tests_switch_library;
|
||||
#[cfg(test)]
|
||||
mod tests_titlebar;
|
||||
#[cfg(test)]
|
||||
mod tests_unsaved;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
@@ -88,6 +95,10 @@ pub type HostAction = Box<dyn FnMut(&mut Session) -> Result<Value, String>>;
|
||||
/// Platform services injected by the host app (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Native file dialogs for commands, shown off the UI thread and answered later
|
||||
/// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside
|
||||
/// dialogs; without it (the web, tests) they answer the commands too.
|
||||
pub picker: Option<pick::Picker>,
|
||||
/// Show an open dialog for photos; returns paths.
|
||||
pub pick_files: Option<PickFiles>,
|
||||
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
|
||||
@@ -171,6 +182,8 @@ pub struct LightcraftApp {
|
||||
shadow: Option<headless::HeadlessView>,
|
||||
/// Synthetic input events (from the control channel) injected one step per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
|
||||
pub(crate) pending_picks: Vec<pick::Pending>,
|
||||
/// Modifiers announced for synthetic input (held from a button down to its release).
|
||||
synthetic_mods: egui::Modifiers,
|
||||
/// Clear `synthetic_mods` on the next frame.
|
||||
@@ -249,6 +262,7 @@ impl LightcraftApp {
|
||||
screenshot_token: 0,
|
||||
shadow: None,
|
||||
synthetic: vec![],
|
||||
pending_picks: vec![],
|
||||
synthetic_mods: egui::Modifiers::NONE,
|
||||
synthetic_mods_release: false,
|
||||
styled: false,
|
||||
@@ -873,6 +887,7 @@ impl LightcraftApp {
|
||||
import::scan_progress(self, &ctx);
|
||||
lightroom_import::progress(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
pick::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Menus bounded by the window (#189). egui keeps a popup inside the window: a menu or submenu
|
||||
//! taller than the room below the menu bar was slid up over the bar, which hid the menu titles,
|
||||
//! and the pointer that had rested on the title then rested on the menu's first row and opened
|
||||
//! its submenu by itself. Here every level of a menu gets only the room it has below the bar (a
|
||||
//! submenu: from its row down, or up to the bar when egui opens it upward) and scrolls inside
|
||||
//! it: wheel, dragging, and ▲ / ▼ strips that scroll while the pointer rests on them, like
|
||||
//! native menus on a small display. The same approach as PhotoCraft's menu bar (its #319).
|
||||
|
||||
use egui::{Id, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
/// Height of a scroll-arrow strip.
|
||||
pub const ARROW: f32 = 16.0;
|
||||
/// Gap kept between a menu and the window edge.
|
||||
const EDGE: f32 = 6.0;
|
||||
/// How fast resting on a scroll arrow scrolls, in points per second.
|
||||
const SPEED: f32 = 600.0;
|
||||
|
||||
fn anchor_id(depth: usize) -> Id {
|
||||
Id::new(("lc-menu-anchor", depth))
|
||||
}
|
||||
|
||||
/// Remember the row the submenu at `depth` (2 = a top-level menu's submenu) hangs from. It is
|
||||
/// read on the next frame: a submenu is drawn while its row is, before the row's rect is known.
|
||||
pub fn set_anchor(ctx: &egui::Context, depth: usize, row: Rect) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(anchor_id(depth), (row, pass)));
|
||||
}
|
||||
|
||||
/// The row the submenu at `depth` hung from on the previous frame (none when it has just opened,
|
||||
/// or when a stored row belongs to a submenu that has closed since).
|
||||
fn anchor(ctx: &egui::Context, depth: usize) -> Option<Rect> {
|
||||
anchor_at(ctx.data(|d| d.get_temp(anchor_id(depth))), ctx.cumulative_pass_nr())
|
||||
}
|
||||
|
||||
/// The anchor `stored` as `(row, pass it was set on)`, if that was the pass before `pass`.
|
||||
fn anchor_at(stored: Option<(Rect, u64)>, pass: u64) -> Option<Rect> {
|
||||
stored.filter(|(_, at)| at.saturating_add(1) == pass).map(|(row, _)| row)
|
||||
}
|
||||
|
||||
/// The height a menu level's rows may take. Every level stays below the menu bar: a top-level
|
||||
/// menu hangs from it, and a submenu that is too tall for the window between its row and the
|
||||
/// bottom, or (opening upward, as egui does when that fits) between the bar and its row, scrolls
|
||||
/// instead of egui sliding it up over the bar. `anchor` is the submenu's row (unknown on its
|
||||
/// first frame), `rows` the level's rows' height when known (the popup shrinks to it), and
|
||||
/// `frame` the popup's margins.
|
||||
pub fn level_room(screen: Rect, bar_bottom: Option<f32>, depth: usize, anchor: Option<Rect>, rows: Option<f32>, frame: f32) -> f32 {
|
||||
let top = bar_bottom.unwrap_or(screen.top()).max(screen.top()) + EDGE;
|
||||
let bottom = screen.bottom() - EDGE;
|
||||
let below_bar = bottom - top;
|
||||
let room = match anchor.filter(|_| depth > 1) {
|
||||
Some(row) => {
|
||||
// Downward from the row, or upward from it, whichever leaves more room. egui opens a
|
||||
// submenu upward only when the downward one doesn't fit the window (it hangs from the
|
||||
// row's top less half the frame margin).
|
||||
let (down, up) = (bottom - row.top(), row.bottom() - top);
|
||||
let opens_up = |h: f32| row.top() - (frame - 2.0) / 2.0 + h + frame > screen.bottom();
|
||||
let want = rows.unwrap_or(f32::INFINITY);
|
||||
if want > down && up > down && opens_up(want.min(up)) { up } else { down }.min(below_bar)
|
||||
}
|
||||
None => below_bar,
|
||||
} - frame;
|
||||
if room.is_finite() { room.max(4.0 * ARROW) } else { 4.0 * ARROW }
|
||||
}
|
||||
|
||||
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
|
||||
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
|
||||
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
|
||||
}
|
||||
|
||||
/// Draw one level of a menu, bounded by the window: when its rows don't fit the room below the
|
||||
/// menu bar they scroll, with scroll arrows at the top and bottom. `depth` is 1 for a top-level
|
||||
/// menu; `bar_bottom` is where the menu bar ends. The level's rect is registered as
|
||||
/// `menu-level:<depth>` for the control channel and tests.
|
||||
pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOnce(&mut Ui)) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let screen = ctx.content_rect();
|
||||
// The menu frame's margin and stroke around the rows.
|
||||
let frame = ui.spacing().menu_margin.sum().y + 2.0;
|
||||
let key = ui.id().with(("lc-menu-level", depth));
|
||||
// Rows' height from the last frame: does this level overflow?
|
||||
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
|
||||
let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame);
|
||||
let over = content.is_some_and(|h| h > room + 0.5);
|
||||
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
let height = if over { room - 2.0 * ARROW } else { room };
|
||||
// The popup's `Ui` is only as tall as the popup was last frame (egui's default area size on
|
||||
// the first), and a scroll area never grows past that. Ask for the room the window has;
|
||||
// auto-shrink then fits the area to its rows, so a menu scrolls only when they don't fit.
|
||||
let out = egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(height)
|
||||
.min_scrolled_height(height)
|
||||
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
|
||||
.show(ui, rows);
|
||||
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
|
||||
crate::widgets::register(&ctx, format!("menu-level:{depth}"), ui.min_rect());
|
||||
let (Some(up), Some(down)) = (up, down) else { return };
|
||||
let t = crate::theme::Tokens::get(&ctx);
|
||||
let x = ui.min_rect().x_range();
|
||||
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
let offset = out.state.offset.y;
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
|
||||
let mut target = offset;
|
||||
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
|
||||
let strip = Rect::from_x_y_ranges(x, strip.y_range());
|
||||
let c = strip.center();
|
||||
let (h, w) = (4.5, 6.0);
|
||||
let tri = if dir < 0.0 {
|
||||
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
|
||||
} else {
|
||||
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_disabled }, egui::Stroke::NONE));
|
||||
let id = key.with(dir < 0.0);
|
||||
let r = ui.interact(strip, id, Sense::hover());
|
||||
r.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }))
|
||||
});
|
||||
if live && pointer_on(ui, strip) {
|
||||
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The wheel scrolls over the arrows too.
|
||||
if pointer_on(ui, strip) {
|
||||
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
|
||||
if wheel != 0.0 {
|
||||
target = (target - wheel).clamp(0.0, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
if target != offset {
|
||||
let mut st = out.state;
|
||||
st.offset.y = target;
|
||||
st.store(&ctx, out.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_room_keeps_every_level_below_the_bar() {
|
||||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 703.0));
|
||||
let (bar, frame) = (Some(42.0), 14.0);
|
||||
let below = 703.0 - EDGE - (42.0 + EDGE) - frame;
|
||||
assert_eq!(level_room(screen, bar, 1, None, None, frame), below);
|
||||
// A submenu from a row near the top opens downward with the room under it…
|
||||
let row = Rect::from_min_size(pos2(100.0, 80.0), vec2(200.0, 30.0));
|
||||
assert_eq!(level_room(screen, bar, 2, Some(row), None, frame), 703.0 - EDGE - 80.0 - frame);
|
||||
// …and from a row near the bottom, upward to the bar.
|
||||
let low = Rect::from_min_size(pos2(100.0, 600.0), vec2(200.0, 30.0));
|
||||
assert_eq!(level_room(screen, bar, 2, Some(low), None, frame), 630.0 - (42.0 + EDGE) - frame);
|
||||
// A short submenu from a low row still fits downward: it keeps that room.
|
||||
assert_eq!(level_room(screen, bar, 2, Some(low), Some(50.0), frame), 703.0 - EDGE - 600.0 - frame);
|
||||
// Never more than the space under the bar, never less than the scroll arrows need.
|
||||
let odd = Rect::from_min_size(pos2(100.0, -50.0), vec2(200.0, 30.0));
|
||||
assert!(level_room(screen, bar, 2, Some(odd), None, frame) <= below);
|
||||
assert_eq!(level_room(Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 40.0)), bar, 1, None, None, frame), 4.0 * ARROW);
|
||||
assert_eq!(level_room(screen, Some(f32::NAN), 1, None, None, frame), level_room(screen, None, 1, None, None, frame));
|
||||
assert_eq!(level_room(Rect::NOTHING, bar, 2, Some(row), None, frame), 4.0 * ARROW);
|
||||
// A top-level menu ignores a stale anchor.
|
||||
assert_eq!(level_room(screen, bar, 1, Some(low), None, frame), below);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_is_read_on_the_next_frame_only() {
|
||||
let row = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 24.0));
|
||||
// set on pass 7: the submenu's room is measured from it on pass 8…
|
||||
assert_eq!(anchor_at(Some((row, 7)), 8), Some(row));
|
||||
// …not on the pass it was set (the row is drawn after its submenu), nor once the
|
||||
// submenu has closed and stopped refreshing it
|
||||
assert_eq!(anchor_at(Some((row, 7)), 7), None);
|
||||
assert_eq!(anchor_at(Some((row, 7)), 9), None);
|
||||
assert_eq!(anchor_at(None, 8), None);
|
||||
assert_eq!(anchor_at(Some((row, u64::MAX)), 0), None);
|
||||
}
|
||||
}
|
||||
@@ -746,22 +746,27 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let start = ui.cursor().left();
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
// Menus hang below the whole top bar and never grow past the window (#189): a tall menu
|
||||
// scrolls instead of egui sliding it up over the titles.
|
||||
let bar_bottom = Some(ui.max_rect().bottom());
|
||||
if total <= max_width {
|
||||
let saved = ui.spacing().item_spacing.x;
|
||||
ui.spacing_mut().item_spacing.x = TITLE_GAP;
|
||||
for (title, items) in &bar {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
egui::Popup::menu(&r).show(|ui| crate::menu_level::level(ui, 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 1, bar_bottom)));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = saved;
|
||||
} else {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
|
||||
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for (title, items) in &bar {
|
||||
ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked));
|
||||
}
|
||||
crate::menu_level::level(ui, 1, bar_bottom, |ui| {
|
||||
for (title, items) in &bar {
|
||||
submenu(ui, title, crate::i18n::tr(title).to_string(), 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 2, bar_bottom));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some((id, params)) = clicked {
|
||||
@@ -776,7 +781,19 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
|
||||
ui.cursor().left() - start
|
||||
}
|
||||
|
||||
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) {
|
||||
/// A submenu row showing `text` at `depth`, whose rows `children` draws one level deeper,
|
||||
/// bounded like every level. The row is registered as `menusub:<label>` (the untranslated
|
||||
/// label), and while its submenu is open it is the anchor the next frame's room is measured from.
|
||||
fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) {
|
||||
let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children));
|
||||
crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect);
|
||||
if r.inner.is_some() {
|
||||
crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// `depth` is 1 for a top-level menu's rows; `bar_bottom` is where the menu bar ends.
|
||||
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>, depth: usize, bar_bottom: Option<f32>) {
|
||||
ui.set_min_width(220.0);
|
||||
for n in nodes {
|
||||
match n {
|
||||
@@ -784,7 +801,9 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
|
||||
ui.separator();
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked));
|
||||
submenu(ui, label, format!(" {}", crate::i18n::tr(label)), depth, bar_bottom, |ui| {
|
||||
nodes_ui(ui, children, mac, clicked, depth + 1, bar_bottom)
|
||||
});
|
||||
}
|
||||
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
|
||||
// a gutter for check marks, like native menus
|
||||
|
||||
+90
-22
@@ -5,6 +5,7 @@ use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::pick::{PickRequest, Picked};
|
||||
use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
|
||||
|
||||
/// (id, label, shortcut, menu path)
|
||||
@@ -1004,7 +1005,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
}
|
||||
let paths = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(),
|
||||
None => {
|
||||
let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS);
|
||||
match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) {
|
||||
Picked::Now(paths) => paths,
|
||||
Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)),
|
||||
}
|
||||
}
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
@@ -1013,8 +1020,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
crate::import::open(app, paths)
|
||||
}
|
||||
"file.importLightroom" => {
|
||||
let path =
|
||||
p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f()));
|
||||
let path = match p.get("path").and_then(Value::as_str).map(str::to_string) {
|
||||
Some(path) => Some(path),
|
||||
None => {
|
||||
let req =
|
||||
PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]);
|
||||
match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later | Picked::Unavailable => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
match path {
|
||||
Some(path) => app.run("library.importLightroom", json!({"path":path})),
|
||||
None => Ok(Value::Null),
|
||||
@@ -1032,7 +1048,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"file.findMissing" => {
|
||||
let folder = match p.get("folder").and_then(Value::as_str) {
|
||||
Some(f) => Some(f.to_string()),
|
||||
None => app.services.pick_folder.as_mut().and_then(|f| f()),
|
||||
None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| {
|
||||
s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])
|
||||
}) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later | Picked::Unavailable => None,
|
||||
},
|
||||
};
|
||||
let Some(folder) = folder else { return Some(Ok(Value::Null)) };
|
||||
// the search (checking every photo's file, walking the folder) runs on a worker thread;
|
||||
@@ -1087,7 +1108,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
// a GPX file → GPS for the selected photos by capture time (one undo step)
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => app.services.pick_tracklog.as_mut().and_then(|f| f().into_iter().next()),
|
||||
None => {
|
||||
let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]);
|
||||
match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later | Picked::Unavailable => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = p.as_object().cloned().unwrap_or_default();
|
||||
@@ -1128,7 +1155,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => app.services.pick_files.as_mut().and_then(|f| f().into_iter().next()),
|
||||
None => {
|
||||
let req =
|
||||
PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS);
|
||||
match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later | Picked::Unavailable => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
match path {
|
||||
Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})),
|
||||
@@ -1157,7 +1191,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
// a folder (searched recursively) into the import review
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => app.services.pick_folder.as_mut().and_then(|f| f()),
|
||||
None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| {
|
||||
s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])
|
||||
}) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later | Picked::Unavailable => None,
|
||||
},
|
||||
};
|
||||
match path {
|
||||
Some(path) => crate::import::open(app, vec![path]),
|
||||
@@ -1167,10 +1206,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"file.importPresets" => {
|
||||
let paths = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => match app.services.pick_preset_files.as_mut() {
|
||||
Some(f) => f(),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
None => {
|
||||
let req = PickRequest::files(
|
||||
Some(crate::i18n::tr("Import Presets").into()),
|
||||
crate::i18n::tr("Presets & Profiles"),
|
||||
crate::pick::PRESET_EXTENSIONS,
|
||||
);
|
||||
match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) {
|
||||
Picked::Now(paths) => paths,
|
||||
Picked::Later => return Some(Ok(Value::Null)),
|
||||
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
@@ -1249,9 +1296,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets"));
|
||||
match app.services.save_preset_file.as_mut() {
|
||||
Some(f) => f(&name),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone());
|
||||
match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later => return Some(Ok(Value::Null)),
|
||||
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1272,10 +1321,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"file.importCurvePresets" => {
|
||||
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => match app.services.pick_curve_preset_files.as_mut() {
|
||||
Some(f) => f(),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
None => {
|
||||
let req = PickRequest::files(
|
||||
Some(crate::i18n::tr("Import Point Curve Presets").into()),
|
||||
crate::i18n::tr("Point Curve Presets"),
|
||||
crate::pick::CURVE_PRESET_EXTENSIONS,
|
||||
);
|
||||
match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) {
|
||||
Picked::Now(paths) => paths,
|
||||
Picked::Later => return Some(Ok(Value::Null)),
|
||||
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
@@ -1295,10 +1352,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
"file.exportCurvePresets" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => match app.services.save_curve_preset_file.as_mut() {
|
||||
Some(f) => f("Point Curves.lccurve"),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
None => {
|
||||
let req = PickRequest::save(
|
||||
crate::i18n::tr("Export Point Curve Presets"),
|
||||
crate::i18n::tr("Point Curve Presets"),
|
||||
&["lccurve"],
|
||||
"Point Curves.lccurve",
|
||||
);
|
||||
match crate::pick::ask(app, id, p, "path", req, |s| {
|
||||
s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect())
|
||||
}) {
|
||||
Picked::Now(v) => v.into_iter().next(),
|
||||
Picked::Later => return Some(Ok(Value::Null)),
|
||||
Picked::Unavailable => return Some(Err("no file dialog on this platform".into())),
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = json!({"path": path});
|
||||
|
||||
@@ -548,13 +548,17 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
|
||||
}
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => x.to_string(),
|
||||
None => match app.services.pick_folder.as_mut() {
|
||||
Some(pick) => match pick() {
|
||||
Some(x) => x,
|
||||
None => return Ok(Value::Null),
|
||||
},
|
||||
None => return Err("no folder dialog on this platform".into()),
|
||||
},
|
||||
None => {
|
||||
let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library"));
|
||||
match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) {
|
||||
crate::pick::Picked::Now(v) => match v.into_iter().next() {
|
||||
Some(x) => x,
|
||||
None => return Ok(Value::Null),
|
||||
},
|
||||
crate::pick::Picked::Later => return Ok(Value::Null),
|
||||
crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
app.session.close_library().map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -8,6 +8,40 @@ use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register};
|
||||
|
||||
/// The bar doubles as the window's title bar: dragging its empty space moves the window and a
|
||||
/// double-click zooms or restores it. Registered before the bar's widgets so they win the click.
|
||||
fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32) {
|
||||
use crate::titlebar::{Gesture, WindowState, command_for};
|
||||
let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom()));
|
||||
register(ui.ctx(), "region:titlebar", bar);
|
||||
let handle = egui::Id::new("titlebar-handle");
|
||||
let resp = ui.interact(bar, handle, Sense::click_and_drag());
|
||||
// a press that began on a button or the search field is theirs: dragging off it must not move the window
|
||||
// (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one)
|
||||
let ctx = ui.ctx().clone();
|
||||
let memo = egui::Id::new("titlebar-press-on-widget");
|
||||
if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) {
|
||||
let hovered = ctx.interaction_snapshot(|s| s.hovered.clone());
|
||||
let on_widget =
|
||||
hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag()));
|
||||
ctx.data_mut(|d| d.insert_temp(memo, on_widget));
|
||||
}
|
||||
let on_widget = ctx.data(|d| d.get_temp::<bool>(memo)).unwrap_or(false);
|
||||
let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) {
|
||||
Some(Gesture::DoubleClicked)
|
||||
} else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget {
|
||||
Some(Gesture::DragStarted)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(g) = gesture {
|
||||
let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen };
|
||||
if let Some(cmd) = command_for(g, window) {
|
||||
ui.ctx().send_viewport_cmd(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let left = if app.integrated_titlebar { 78 } else { 10 };
|
||||
@@ -16,6 +50,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
if app.integrated_titlebar {
|
||||
window_handle(app, ui, full, left as f32, 12.0);
|
||||
}
|
||||
let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0);
|
||||
if !app.native_menu {
|
||||
// leave room for the in-window menus left of the (centred) search field
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Native file dialogs for commands, off the UI thread (#191). A synchronous dialog blocks the
|
||||
//! thread that shows it, and on the desktop that was the UI thread: the window stopped answering
|
||||
//! the compositor (GNOME marked LightCraft "not responding" and offered to kill it), the menu the
|
||||
//! command came from stayed drawn, and the control channel went quiet until the dialog closed.
|
||||
//!
|
||||
//! A host that can run a dialog elsewhere installs a [`Picker`] in `Services`: it starts the
|
||||
//! dialog and answers through a channel. A command that needs a path the caller didn't give
|
||||
//! [`ask`]s for one and returns at once; when the dialog closes, [`poll`] runs the command again
|
||||
//! with the answer filled in under the parameter it was missing. Hosts without a `Picker` (the
|
||||
//! web, where browser pickers are asynchronous already; tests) answer through the synchronous
|
||||
//! services as before.
|
||||
|
||||
use crate::{LightcraftApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
/// What a dialog chooses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PickKind {
|
||||
/// Any number of files.
|
||||
Files,
|
||||
/// One file.
|
||||
File,
|
||||
/// One folder.
|
||||
Folder,
|
||||
/// Where to save one file.
|
||||
Save,
|
||||
}
|
||||
|
||||
/// A dialog to show. Texts are translated already.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PickRequest {
|
||||
pub kind: PickKind,
|
||||
/// The dialog's title; the host's default when `None`.
|
||||
pub title: Option<String>,
|
||||
/// One file-type filter: its name and the extensions it admits (without dots).
|
||||
pub filter: Option<(String, &'static [&'static str])>,
|
||||
/// [`PickKind::Save`]: the suggested file name.
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
impl PickRequest {
|
||||
pub fn files(title: Option<String>, filter: impl Into<String>, extensions: &'static [&'static str]) -> Self {
|
||||
PickRequest { kind: PickKind::Files, title, filter: Some((filter.into(), extensions)), file_name: None }
|
||||
}
|
||||
|
||||
pub fn file(title: impl Into<String>, filter: impl Into<String>, extensions: &'static [&'static str]) -> Self {
|
||||
PickRequest { kind: PickKind::File, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: None }
|
||||
}
|
||||
|
||||
pub fn folder(title: impl Into<String>) -> Self {
|
||||
PickRequest { kind: PickKind::Folder, title: Some(title.into()), filter: None, file_name: None }
|
||||
}
|
||||
|
||||
pub fn save(title: impl Into<String>, filter: impl Into<String>, extensions: &'static [&'static str], file_name: impl Into<String>) -> Self {
|
||||
PickRequest { kind: PickKind::Save, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: Some(file_name.into()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What File ▸ Import Profiles & Presets… opens.
|
||||
pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"];
|
||||
/// What Import Point Curve Presets… opens.
|
||||
pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"];
|
||||
|
||||
/// The host's dialog starter: shows the request without blocking and answers through the
|
||||
/// channel with the chosen paths (none = cancelled). `Err` when no dialog could be started.
|
||||
pub type Picker = Box<dyn FnMut(PickRequest) -> Result<Receiver<Vec<String>>, String>>;
|
||||
|
||||
/// A dialog that is up for a command. When it closes, the command runs again with the answer
|
||||
/// under `key`.
|
||||
pub struct Pending {
|
||||
pub command: String,
|
||||
params: Value,
|
||||
key: &'static str,
|
||||
kind: PickKind,
|
||||
rx: Receiver<Vec<String>>,
|
||||
}
|
||||
|
||||
/// How a command's request for a path was answered.
|
||||
pub enum Picked {
|
||||
/// A synchronous dialog answered (an empty list: cancelled).
|
||||
Now(Vec<String>),
|
||||
/// The dialog is up (or was already); the command runs again when it closes.
|
||||
Later,
|
||||
/// This host has no such dialog.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Ask for a path for `command`, which was run with `params` but without `key`. With a [`Picker`]
|
||||
/// the dialog runs off the UI thread and the command comes back through [`poll`] with `key` filled
|
||||
/// in; a dialog already up for the command is not opened twice. Without one, `now` asks the
|
||||
/// synchronous service.
|
||||
pub fn ask(
|
||||
app: &mut LightcraftApp,
|
||||
command: &str,
|
||||
params: &Value,
|
||||
key: &'static str,
|
||||
request: PickRequest,
|
||||
now: impl FnOnce(&mut Services) -> Option<Vec<String>>,
|
||||
) -> Picked {
|
||||
if app.pending_picks.iter().any(|p| p.command == command) {
|
||||
return Picked::Later;
|
||||
}
|
||||
if let Some(start) = app.services.picker.as_mut() {
|
||||
let kind = request.kind;
|
||||
match start(request) {
|
||||
Ok(rx) => {
|
||||
app.pending_picks.push(Pending { command: command.to_string(), params: params.clone(), key, kind, rx });
|
||||
return Picked::Later;
|
||||
}
|
||||
Err(e) => log::warn!("{command}: no background file dialog ({e}); asking on the UI thread"),
|
||||
}
|
||||
}
|
||||
match now(&mut app.services) {
|
||||
Some(paths) => Picked::Now(paths),
|
||||
None => Picked::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` with the dialog's answer under `key`: the whole list for a `paths` key, the first
|
||||
/// path otherwise.
|
||||
pub fn with_answer(params: &Value, key: &str, kind: PickKind, paths: &[String]) -> Value {
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
let answer = match kind {
|
||||
PickKind::Files => json!(paths),
|
||||
_ => json!(paths.first().cloned().unwrap_or_default()),
|
||||
};
|
||||
p.insert(key.to_string(), answer);
|
||||
Value::Object(p)
|
||||
}
|
||||
|
||||
/// Each frame: run the commands whose dialogs have closed with something chosen; forget the
|
||||
/// cancelled ones and those whose dialog went away without an answer.
|
||||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if app.pending_picks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut answered = Vec::new();
|
||||
let mut waiting = Vec::new();
|
||||
for p in std::mem::take(&mut app.pending_picks) {
|
||||
match p.rx.try_recv() {
|
||||
Ok(paths) if paths.is_empty() => {}
|
||||
Ok(paths) => answered.push((p, paths)),
|
||||
Err(TryRecvError::Empty) => waiting.push(p),
|
||||
Err(TryRecvError::Disconnected) => log::warn!("{}: the file dialog ended without an answer", p.command),
|
||||
}
|
||||
}
|
||||
if !waiting.is_empty() {
|
||||
// the host repaints when a dialog closes; this is the net under it
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
app.pending_picks = waiting;
|
||||
for (p, paths) in answered {
|
||||
let params = with_answer(&p.params, p.key, p.kind, &paths);
|
||||
if let Err(e) = app.run(&p.command, params) {
|
||||
app.toast_error(ctx, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Dialog;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const SETTLE: Duration = Duration::from_secs(20);
|
||||
|
||||
type Asked = Arc<Mutex<Vec<(PickRequest, Sender<Vec<String>>)>>>;
|
||||
|
||||
/// A windowless app whose host shows dialogs "elsewhere": each request is recorded with the
|
||||
/// sender that answers it. Frames run through the headless harness, so [`poll`] and the import
|
||||
/// scan happen as in the app.
|
||||
fn app_with_picker() -> (Headless, Asked) {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
let asked: Asked = Default::default();
|
||||
let a = asked.clone();
|
||||
app.services.picker = Some(Box::new(move |req| {
|
||||
let (tx, rx) = channel();
|
||||
a.lock().unwrap().push((req, tx));
|
||||
Ok(rx)
|
||||
}));
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(SETTLE);
|
||||
(h, asked)
|
||||
}
|
||||
|
||||
fn photo_in(dir: &std::path::Path) -> String {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]);
|
||||
let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() };
|
||||
let path = dir.join("one.png");
|
||||
std::fs::write(&path, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// The import review's sources once frames have run (the review opens after a background
|
||||
/// scan of the chosen paths).
|
||||
fn import_sources(h: &mut Headless) -> Option<Vec<String>> {
|
||||
h.settle(SETTLE);
|
||||
match &h.app.ui.dialog {
|
||||
Some(Dialog::Import { opts }) => Some(opts.sources.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_photos_waits_for_its_dialog_and_runs_with_the_answer() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-pick-{}", std::process::id()));
|
||||
let photo = photo_in(&dir);
|
||||
let (mut h, asked) = app_with_picker();
|
||||
// the command returns at once: no review of its own yet, the native dialog is up
|
||||
assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null);
|
||||
assert!(import_sources(&mut h).is_none());
|
||||
assert_eq!(h.app.pending_picks.len(), 1);
|
||||
{
|
||||
let asked = asked.lock().unwrap();
|
||||
assert_eq!(asked.len(), 1);
|
||||
assert_eq!(asked[0].0.kind, PickKind::Files);
|
||||
assert_eq!(asked[0].0.filter.as_ref().map(|f| f.1), Some(lightcraft_engine::import::EXTENSIONS));
|
||||
}
|
||||
// asking again while it is up opens no second dialog
|
||||
assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(asked.lock().unwrap().len(), 1);
|
||||
assert_eq!(h.app.pending_picks.len(), 1, "still waiting");
|
||||
// the user chooses a photo: the command runs again with it and opens the import review
|
||||
asked.lock().unwrap()[0].1.send(vec![photo.clone()]).unwrap();
|
||||
assert_eq!(import_sources(&mut h), Some(vec![photo]));
|
||||
assert!(h.app.pending_picks.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_or_vanished_dialog_runs_nothing() {
|
||||
let (mut h, asked) = app_with_picker();
|
||||
h.app.run("file.addPhotos", json!({})).unwrap();
|
||||
asked.lock().unwrap()[0].1.send(vec![]).unwrap();
|
||||
assert!(import_sources(&mut h).is_none(), "cancelled: no import review");
|
||||
assert!(h.app.pending_picks.is_empty());
|
||||
// the dialog thread ends without answering (the host dropped the sender)
|
||||
h.app.run("file.addFolder", json!({})).unwrap();
|
||||
assert_eq!(h.app.pending_picks.len(), 1);
|
||||
asked.lock().unwrap().pop();
|
||||
assert!(import_sources(&mut h).is_none());
|
||||
assert!(h.app.pending_picks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_dialog_fills_in_one_path_and_a_save_dialog_names_the_file() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-pick-folder-{}", std::process::id()));
|
||||
let _photo = photo_in(&dir);
|
||||
let (mut h, asked) = app_with_picker();
|
||||
h.app.run("file.addFolder", json!({})).unwrap();
|
||||
assert_eq!(asked.lock().unwrap()[0].0.kind, PickKind::Folder);
|
||||
asked.lock().unwrap()[0].1.send(vec![dir.to_string_lossy().to_string()]).unwrap();
|
||||
assert_eq!(import_sources(&mut h), Some(vec![dir.to_string_lossy().to_string()]));
|
||||
// a save dialog carries the suggested name; the command's other parameters survive
|
||||
h.app.run("file.exportPresets", json!({"group": "Mine"})).unwrap();
|
||||
let req = asked.lock().unwrap().last().map(|(r, _)| r.clone()).unwrap();
|
||||
assert_eq!(req.kind, PickKind::Save);
|
||||
assert_eq!(req.file_name.as_deref(), Some("Mine.lcpreset"));
|
||||
let p = with_answer(&json!({"group": "Mine"}), "path", PickKind::Save, &["/tmp/Mine.lcpreset".to_string(), "/tmp/other".to_string()]);
|
||||
assert_eq!(p, json!({"group": "Mine", "path": "/tmp/Mine.lcpreset"}));
|
||||
assert_eq!(with_answer(&Value::Null, "paths", PickKind::Files, &["a".to_string(), "b".to_string()]), json!({"paths": ["a", "b"]}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_picker_the_synchronous_dialog_answers_at_once() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-pick-sync-{}", std::process::id()));
|
||||
let photo = photo_in(&dir);
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
let p = photo.clone();
|
||||
app.services.pick_files = Some(Box::new(move || vec![p.clone()]));
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.app.run("file.addPhotos", json!({})).unwrap();
|
||||
assert!(h.app.pending_picks.is_empty());
|
||||
assert_eq!(import_sources(&mut h), Some(vec![photo]));
|
||||
// and a host with neither shows nothing, as before
|
||||
let mut bare = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
|
||||
assert_eq!(bare.run("file.addPhotos", json!({})).unwrap(), Value::Null);
|
||||
assert!(bare.run("file.importPresets", json!({})).is_err(), "no dialog on this platform");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! #189: the in-window menu bar's menus and submenus stay below the bar. On a short window a
|
||||
//! tall menu (Photo) used to be slid up over the bar by egui, hiding the menu titles, and the
|
||||
//! pointer left on the title then opened the first row's submenu by itself.
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
const T: Duration = Duration::from_secs(5);
|
||||
const SETTLE: Duration = Duration::from_secs(10);
|
||||
|
||||
fn app(size: [f32; 2]) -> Headless {
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, size, 1.0);
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
fn widgets_with(h: &Headless, prefix: &str) -> Vec<(String, egui::Rect)> {
|
||||
h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).cloned().collect()
|
||||
}
|
||||
|
||||
/// The open level `id` hangs below every menu title and ends inside the window.
|
||||
fn check_bar_clear(h: &Headless, id: &str, what: &str) {
|
||||
let level = widget(h, id).unwrap_or_else(|| panic!("{what}: no {id} on screen"));
|
||||
let titles = widgets_with(h, "menu:");
|
||||
assert!(!titles.is_empty(), "{what}: no menu titles");
|
||||
let bar_bottom = titles.iter().map(|(_, r)| r.bottom()).fold(f32::MIN, f32::max);
|
||||
assert!(level.top() >= bar_bottom - 0.5, "{what}: {id} {level:?} starts above the bar's bottom {bar_bottom}");
|
||||
assert!(level.bottom() <= h.size.y + 0.5, "{what}: {id} {level:?} runs past the window's bottom {}", h.size.y);
|
||||
for (title, r) in &titles {
|
||||
assert!(!level.intersects(*r), "{what}: {id} {level:?} covers {title} {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_menu_or_submenu_covers_the_menu_bar() {
|
||||
// The reporter's 1280 × 720 display less the title bar, and a shorter window where even a
|
||||
// scrolled menu's submenus must open upward.
|
||||
let sizes = [[1280.0, 703.0], [1160.0, 560.0]];
|
||||
let mut submenus = 0;
|
||||
for size in sizes {
|
||||
for top in ["Photo", "File", "View"] {
|
||||
let what = format!("{size:?} {top}");
|
||||
let mut h = app(size);
|
||||
assert!(widget(&h, "menu-level:1").is_none(), "{what}: a menu is open before any click");
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("menu:{top}")}), T);
|
||||
assert_eq!(r["ok"], true, "{what}: {r}");
|
||||
h.settle(SETTLE);
|
||||
check_bar_clear(&h, "menu-level:1", &what);
|
||||
// Hover each submenu row in view, top to bottom: rows low in the menu open upward.
|
||||
let level = widget(&h, "menu-level:1").unwrap_or_else(|| panic!("{what}: menu"));
|
||||
let rows: Vec<String> = widgets_with(&h, "menusub:").into_iter().filter(|(_, r)| level.contains_rect(*r)).map(|(id, _)| id).collect();
|
||||
for row in rows {
|
||||
let r = h.request("ui.hoverWidget", json!({"id": row}), T);
|
||||
assert_eq!(r["ok"], true, "{what}: {r}");
|
||||
h.settle(SETTLE);
|
||||
if widget(&h, "menu-level:2").is_none() {
|
||||
continue;
|
||||
}
|
||||
submenus += 1;
|
||||
check_bar_clear(&h, "menu-level:2", &format!("{what} › {row}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(submenus >= 6, "opened {submenus} submenus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_menu_scrolls_instead_of_growing_past_the_window() {
|
||||
let mut h = app([1160.0, 420.0]);
|
||||
let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
check_bar_clear(&h, "menu-level:1", "420 pt window");
|
||||
let level = widget(&h, "menu-level:1").expect("Photo menu");
|
||||
// Not every row fits: some are scrolled out of view, none drawn outside the menu.
|
||||
let rows = widgets_with(&h, "menusub:");
|
||||
assert!(!rows.is_empty(), "no submenu rows registered");
|
||||
assert!(rows.iter().any(|(_, r)| !level.contains_rect(*r)), "every row fits a 420 pt window: {rows:?}");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The integrated title bar behaves like a native one: drag the empty space to move the window,
|
||||
//! double-click it to zoom and again to restore. Buttons and the search field keep their clicks.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::ViewportCommand;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Given LightCraft with the integrated title bar (macOS) in a 1200×800 window.
|
||||
fn integrated() -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
app.integrated_titlebar = true;
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(Duration::from_secs(120));
|
||||
h
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> egui::Rect {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
|
||||
}
|
||||
|
||||
/// Empty top-bar space: where the traffic lights sit, left of the sidebar button.
|
||||
fn empty_spot(h: &Headless) -> egui::Pos2 {
|
||||
let bar = widget(h, "region:titlebar");
|
||||
egui::pos2(bar.left() + 20.0, bar.center().y)
|
||||
}
|
||||
|
||||
fn play(h: &mut Headless) {
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: double-clicking the empty top bar zooms the window, and again restores it
|
||||
#[test]
|
||||
fn double_click_on_the_empty_bar_zooms_then_restores() {
|
||||
let mut h = integrated();
|
||||
let p = empty_spot(&h);
|
||||
h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T);
|
||||
play(&mut h);
|
||||
assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true)]);
|
||||
// a pause longer than egui's multi-click window, or the second pair would count as a triple click
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T);
|
||||
play(&mut h);
|
||||
assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true), ViewportCommand::Maximized(false)]);
|
||||
}
|
||||
|
||||
// Scenario: dragging the empty top bar starts a window move
|
||||
#[test]
|
||||
fn dragging_the_empty_bar_moves_the_window() {
|
||||
let mut h = integrated();
|
||||
let p = empty_spot(&h);
|
||||
h.request("ui.drag", json!({"x": p.x, "y": p.y, "toX": p.x + 120.0, "toY": p.y + 40.0}), T);
|
||||
play(&mut h);
|
||||
assert_eq!(h.window_commands, vec![ViewportCommand::StartDrag]);
|
||||
}
|
||||
|
||||
// Scenario: a single click on the empty bar does nothing
|
||||
#[test]
|
||||
fn single_click_on_the_empty_bar_does_nothing() {
|
||||
let mut h = integrated();
|
||||
let p = empty_spot(&h);
|
||||
h.request("ui.click", json!({"x": p.x, "y": p.y}), T);
|
||||
play(&mut h);
|
||||
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
|
||||
}
|
||||
|
||||
// Scenario: double-clicking a button does not zoom the window
|
||||
#[test]
|
||||
fn double_click_on_a_button_does_not_zoom() {
|
||||
let mut h = integrated();
|
||||
let r = widget(&h, "icon:help");
|
||||
h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T);
|
||||
play(&mut h);
|
||||
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
|
||||
}
|
||||
|
||||
// Scenario: pressing a button and dragging off it does not move the window (as in a native title bar)
|
||||
#[test]
|
||||
fn dragging_from_a_button_does_not_move_the_window() {
|
||||
let mut h = integrated();
|
||||
let r = widget(&h, "icon:help");
|
||||
h.request("ui.drag", json!({"x": r.center().x, "y": r.center().y, "toX": r.center().x - 150.0, "toY": r.center().y + 30.0}), T);
|
||||
play(&mut h);
|
||||
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
|
||||
}
|
||||
|
||||
// Scenario: double-clicking the search field selects a word, it does not zoom the window
|
||||
#[test]
|
||||
fn double_click_in_the_search_field_does_not_zoom() {
|
||||
let mut h = integrated();
|
||||
let r = widget(&h, "field:search");
|
||||
h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T);
|
||||
play(&mut h);
|
||||
assert!(h.window_commands.is_empty(), "{:?}", h.window_commands);
|
||||
}
|
||||
|
||||
// Scenario: a window with its native title bar (Windows, Linux) leaves the bar alone
|
||||
#[test]
|
||||
fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
h.settle(Duration::from_secs(120));
|
||||
assert!(!h.app.integrated_titlebar);
|
||||
let bar = h.app.widgets.iter().find(|(w, _)| w == "region:titlebar");
|
||||
assert!(bar.is_none());
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top
|
||||
//! bar takes its place, so dragging and double-click-to-zoom have to be done here).
|
||||
//!
|
||||
//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the
|
||||
//! window's current state to the viewport command the host should run.
|
||||
|
||||
use egui::ViewportCommand;
|
||||
|
||||
/// What the user did on the bar's empty space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Gesture {
|
||||
/// A primary-button drag began: the window follows the pointer.
|
||||
DragStarted,
|
||||
/// A primary-button double-click: zoom the window, or restore it when already zoomed.
|
||||
DoubleClicked,
|
||||
}
|
||||
|
||||
/// The window as the host reports it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct WindowState {
|
||||
/// Zoomed (macOS) / maximized (elsewhere).
|
||||
pub maximized: bool,
|
||||
/// In the OS's full-screen mode.
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom.
|
||||
pub fn command_for(gesture: Gesture, window: WindowState) -> Option<ViewportCommand> {
|
||||
if window.fullscreen {
|
||||
return None;
|
||||
}
|
||||
match gesture {
|
||||
Gesture::DragStarted => Some(ViewportCommand::StartDrag),
|
||||
Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false };
|
||||
const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false };
|
||||
const FULL: WindowState = WindowState { maximized: false, fullscreen: true };
|
||||
|
||||
// Scenario: dragging the title bar moves the window
|
||||
#[test]
|
||||
fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() {
|
||||
assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag));
|
||||
}
|
||||
|
||||
// Scenario: a zoomed window can still be dragged (macOS lets you pull it free)
|
||||
#[test]
|
||||
fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() {
|
||||
assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag));
|
||||
}
|
||||
|
||||
// Scenario: double-click zooms
|
||||
#[test]
|
||||
fn given_a_normal_window_when_double_clicking_then_it_zooms() {
|
||||
assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true)));
|
||||
}
|
||||
|
||||
// Scenario: double-click again restores
|
||||
#[test]
|
||||
fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() {
|
||||
assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false)));
|
||||
}
|
||||
|
||||
// Scenario: full screen ignores both
|
||||
#[test]
|
||||
fn given_a_full_screen_window_then_neither_gesture_does_anything() {
|
||||
assert_eq!(command_for(Gesture::DragStarted, FULL), None);
|
||||
assert_eq!(command_for(Gesture::DoubleClicked, FULL), None);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF and Canon CR3 starting look
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
|
||||
|
||||
ARW, NEF, RW2, RAF and CR3 decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
|
||||
## Colour and tone are separate
|
||||
|
||||
@@ -84,3 +84,10 @@ Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, he
|
||||
An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `ColorSpace` (0x001e: 1 = sRGB, 2 = Adobe RGB). This fallback is applied before transfer decoding and resizing, and used by quick previews, preview-only RAW and the existing camera-look reference decode. A JPEG's own metadata takes precedence. No fitting algorithm, training split, highlight reconstruction or white-balance control changes are included. Render cache version 12 invalidates older previews.
|
||||
|
||||
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
|
||||
|
||||
### Canon CR2 and Pentax PEF
|
||||
|
||||
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening
|
||||
flat and desaturated: the camera's preview first, then the neutral fallback). They now use the same fit, gates and
|
||||
relative white balance. This change was not measured on the corpus: the gates keep the fallback for any file whose fit
|
||||
doesn't pass, so a file can only stay as before or move closer to its camera JPEG. Per-model numbers are still to come.
|
||||
@@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||||
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
|
||||
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) |
|
||||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||||
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
|
||||
|
||||
+5
-2
@@ -22,8 +22,11 @@ CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers
|
||||
support lossless RAW and three-level C-RAW with horizontal tiles. Uniform
|
||||
quantization values 4–43 are independently verified. Version `0x200` C-RAW
|
||||
supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and
|
||||
its adaptive quantization map. Other unverified coding variants return explicit
|
||||
unsupported errors. Header probing validates the same coding variants and
|
||||
its adaptive quantization map. C-RAW is quantized, so next to clipped highlights
|
||||
its reconstruction can overshoot the sensor range by a few quantization steps
|
||||
(up to 36 codes above 16383 in 17 CC0 files); those samples are clamped to the
|
||||
CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data.
|
||||
Other unverified coding variants return explicit unsupported errors. Header probing validates the same coding variants and
|
||||
tile/plane layout as full decoding.
|
||||
|
||||
`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a
|
||||
|
||||
@@ -69,6 +69,16 @@ rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a spec
|
||||
from every program started with it. Help ▸ System Info and `app.gpu` show the adapter and backend in
|
||||
use (e.g. `Intel(R) UHD Graphics 630 (Dx12)`).
|
||||
|
||||
**Window never appears, "not responding" (Windows, NVIDIA; issue #374).** With *Shader Cache Size*
|
||||
set to **Disabled** in NVIDIA Control Panel (Manage 3D settings → Global Settings), NVIDIA's driver
|
||||
walks the whole system drive while the window's GPU device is created (`D3D12CreateDevice` →
|
||||
`nvwgf2umx.dll` → `NvMemMapStoragex.dll!TotalDiskUsage`), before the first frame: a black,
|
||||
unresponsive window for minutes. The Vulkan driver shares that code, so another backend doesn't
|
||||
help. Set *Shader Cache Size* back to **Driver Default** (or any size); the setting is global, there
|
||||
is no per-program override. Other programs that create a DX12 or Vulkan device at startup hang the
|
||||
same way (PhotoCraft does). It is a driver issue: nothing in LightCraft itself
|
||||
walks the drive.
|
||||
|
||||
## Where it is used
|
||||
- `lightcraft_engine::media::develop` (called by every `RenderJob`): loupe / before / compare views,
|
||||
`render_now` (CLI, MCP, control channel renders) and exports render on the GPU when one is
|
||||
|
||||
+5
-4
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
|
||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| X. Cross-cutting behaviours (BEHAV) | 17 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
|
||||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
||||
| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
| **Total** | 392 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 514 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 161.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
@@ -287,7 +287,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF and Canon CR3 get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
|
||||
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
|
||||
@@ -522,6 +522,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
|
||||
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
|
||||
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
|
||||
| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) |
|
||||
|
||||
## Y. Menus
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
|
||||
|
||||
### RAW decoding
|
||||
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
|
||||
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
|
||||
|
||||
Reference in new issue
Block a user