Merge remote-tracking branch 'origin/main' into pr/gpu-setting-no-wait

This commit is contained in:
OneSlips
2026-10-26 14:50:08 +10:00
57 changed files with 11597 additions and 687 deletions
Generated
+603 -183
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -10,8 +10,10 @@ rust-version = "1.95"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
@@ -61,7 +63,7 @@ dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and h
# formats
quick-xml = "0.41"
miniz_oxide = "0.8"
miniz_oxide = "0.9"
# codecs
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] }
+1 -1
View File
@@ -348,7 +348,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
+1 -1
View File
@@ -305,7 +305,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
+1 -1
View File
@@ -320,7 +320,7 @@ LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,
在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/
Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。
- **最大的缺口:**
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
- **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md);
- **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法;
- **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
+6 -1
View File
@@ -481,7 +481,12 @@ impl WebApp {
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
.with_chinese_font(chinese_font);
let ui_written = files.get("ui.json").unwrap_or_default();
if let Ok(ui) = serde_json::from_slice::<UiState>(&ui_written) {
// an arrangement from an older docking version starts again from the default
let mut saved = serde_json::from_slice::<serde_json::Value>(&ui_written).unwrap_or_default();
if let Some(fields) = saved.as_object_mut() {
lightcraft_ui_egui::docking::drop_outdated(fields);
}
if let Ok(ui) = serde_json::from_value::<UiState>(saved) {
app.ui = ui;
}
app.ui = app.ui.sanitized();
+3
View File
@@ -55,3 +55,6 @@ objc2-foundation = { workspace = true }
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+12 -4
View File
@@ -261,13 +261,15 @@ fn load_prefs(in_memory: bool) -> (Option<UiState>, Option<String>, bool) {
load_prefs_at(&path, !in_memory)
}
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands.
/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. An
/// arrangement from an older docking version starts again from the default without a warning.
fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> {
let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
let fields = saved.as_object_mut().ok_or("settings must be an object")?;
lightcraft_ui_egui::docking::drop_outdated(fields);
let recovered =
fields.get("docking").is_some_and(|value| match serde_json::from_value::<Option<lightcraft_ui_egui::docking::Workspace>>(value.clone()) {
Ok(Some(workspace)) => workspace.validate().is_err(),
Ok(Some(workspace)) => lightcraft_ui_egui::docking::validate(&workspace).is_err(),
Ok(None) => false,
Err(_) => true,
});
@@ -378,7 +380,7 @@ impl PrefsWriter {
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
workspace.validate()?;
lightcraft_ui_egui::docking::validate(workspace)?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
@@ -881,6 +883,11 @@ fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: s
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
@@ -1009,6 +1016,7 @@ fn main() -> eframe::Result {
// HDR display: SDR unless the surface, platform and display all allow HDR
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
app.set_startup_update_check(true);
if let Some(ui) = prefs {
app.ui = ui;
}
@@ -1277,7 +1285,7 @@ mod tests {
restored_app.ui = restored.unwrap();
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
assert_eq!(
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect,
[70.0, 80.0, 310.0, 500.0]
);
std::fs::remove_dir_all(d).unwrap();
+1
View File
@@ -22,6 +22,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/ILCE-7RM5.json` | Sony ILCE-7RM5 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Yule (Brian) Wang (@peaperfish) | original work: fitted by `lightcraft-cli calibrate` to 220 usable ILCE-7RM5 ARWs of my own (of 241, 2025 to 2026, Standard creative style; Sigma 24-70mm F2.8 DG DN II Art and Sony FE 200-600mm F5.6-6.3 G OSS) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-26 | none |
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,3 +24,4 @@ web-time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
dhat = { workspace = true }
+35 -2
View File
@@ -9,7 +9,7 @@
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
use std::io::{Read, Write};
use std::io::{Read, Seek, Write};
use std::path::Path;
use lightcraft_raster::Rgb32f;
@@ -216,6 +216,10 @@ fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
r.read_exact(&mut e)?;
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
// a strip never compresses to nothing (it has at least one row, so at least one deflated pixel)
if l == 0 {
return Err(ProductError::Format("a strip is empty".into()));
}
// a strip never compresses to much more than its raw size
if l > 12 * h.strip * h.width + 4096 {
return Err(ProductError::Format("a strip is larger than it can be".into()));
@@ -305,7 +309,6 @@ pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize
}
// the picture rows and columns the blocks cover
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
let lut = f16_table();
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
@@ -318,6 +321,21 @@ pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
// skip the strips before the first one wanted
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
// The output is reserved only after the wanted strips' compressed bytes are present and match their checksums:
// a malformed cache whose strips are empty, damaged or short are rejected here, before the (large) result is
// allocated, not after. Reading them twice is cheap next to decoding them, and the fold below re-seeks here.
let strips_at = f.stream_position()?;
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
for &s in &strips {
let Some(&(len, expected)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
let mut packed = allocate(len, 0u8)?;
f.read_exact(&mut packed)?;
if crc(&packed) != expected {
return Err(ProductError::Format(format!("strip {s} is damaged")));
}
}
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
f.seek(std::io::SeekFrom::Start(strips_at))?;
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
for group in strips.chunks(GROUP) {
let mut packed = Vec::with_capacity(group.len());
@@ -422,6 +440,21 @@ mod tests {
let _ = std::fs::remove_file(p);
}
#[test]
fn an_empty_strip_entry_is_rejected_by_the_header() {
let p = temp("empty-strip");
let header = serde_json::to_vec(&Header { width: 16, height: 1, strip: STRIP_ROWS, key: "k".into() }).unwrap();
let mut bytes = Vec::from(MAGIC.as_slice());
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
bytes.extend_from_slice(&header);
// one strip with a zero-length entry: it has a row, so its deflated bytes are never empty
bytes.extend_from_slice(&[0; 8]);
std::fs::write(&p, bytes).unwrap();
assert!(matches!(read_header_at(&p), Err(ProductError::Format(why)) if why.contains("empty")));
let _ = std::fs::remove_file(p);
}
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
}
+50
View File
@@ -0,0 +1,50 @@
//! Peak heap of a `read_window` that must reject a product's strips (`crates/denoise-core/src/product.rs`), measured
//! with dhat's testing mode. The result is allocated only after the wanted strips' checksums are verified, so a cache
//! whose compressed bytes are damaged or absent fails without first reserving the (multi-megabyte) output. One test
//! per binary (dhat's profiler is global).
use lightcraft_denoise_core::product::read_window;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
}
/// A 2000 × 1024 product of key `k` (a 24 MB result): its 8 strips each claim 8 bytes that do not match their
/// checksum, and the file is exactly the length its header and table declare. A 24 MB peak means the output was
/// reserved; the cap here bounds the header, the table and the strips' own bytes.
fn damaged_product() -> Vec<u8> {
let header = br#"{"width":2000,"height":1024,"strip":128,"key":"k"}"#;
const STRIP_LEN: usize = 8;
let mut bytes = Vec::from(b"LCDN".as_slice());
bytes.extend_from_slice(&[1, 0, 0, 0]);
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
bytes.extend_from_slice(header);
let expected_len = 12 + header.len() + 8 * 8 + 8 * STRIP_LEN;
for _ in 0..8 {
// 8 claimed bytes whose recorded checksum does not match theirs (0xAA bytes do not have this CRC)
bytes.extend_from_slice(&(STRIP_LEN as u32).to_le_bytes());
bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
}
for _ in 0..8 {
bytes.extend_from_slice(&[0xAAu8; STRIP_LEN]);
}
assert_eq!(bytes.len(), expected_len, "the crafted file lies about its length");
bytes
}
#[test]
fn a_damaged_product_is_rejected_before_its_output_is_allocated() {
let bytes = damaged_product();
let p = temp("damaged-product-heap");
std::fs::write(&p, &bytes).unwrap();
let _profiler = dhat::Profiler::builder().testing().build();
let why = read_window(&p, "k", None, 1, None).err().unwrap().to_string();
let peak = dhat::HeapStats::get().max_bytes;
let _ = std::fs::remove_file(p);
assert!(why.contains("damaged"), "{why}");
eprintln!("peak heap {peak} bytes");
assert!(peak < 4 << 20, "peak heap {peak} bytes: the output was reserved before the strips were checked");
}
+71 -5
View File
@@ -4,6 +4,12 @@
use crate::net::{Net, NetError, Op, TensorId};
use crate::run::{Error, TileRunner};
/// The tensors' size cap: the same one the real runner's buffers share (`crates/denoise/src/cpu.rs`). A hostile `Net`
/// only ever has to satisfy the final shape: a `ConvT2` doubles the sides and a `MaxPool` halves them, so a small
/// accepted graph can zoom its intermediates far out and back. Every tensor is counted here, and a run whose layers
/// would retain more than one real tile's whole working set is refused before the memory behind it is reserved.
const MAX_VALUES: usize = 128 * 1024 * 1024;
/// A tensor in `[channels][side][side]` order.
struct T {
channels: usize,
@@ -11,12 +17,36 @@ struct T {
data: Vec<f32>,
}
/// A tensor's plain float count, in bounds.
fn count(channels: usize, side: usize) -> Result<usize, NetError> {
channels
.checked_mul(side)
.and_then(|n| n.checked_mul(side))
.filter(|&n| n > 0 && n <= MAX_VALUES)
.ok_or_else(|| NetError::Invalid("a tensor has no size or is too large".into()))
}
/// A zeroed tensor, keeping the memory the run keeps alive in bounds (`tensors` retains every layer's result).
fn tensor(channels: usize, side: usize, total: &mut usize) -> Result<Vec<f32>, NetError> {
let n = count(channels, side)?;
*total = (*total).saturating_add(n);
if *total > MAX_VALUES {
return Err(NetError::Invalid("the tile needs too much working memory".into()));
}
let mut data = Vec::new();
data.try_reserve_exact(n).map_err(|_| NetError::Invalid("not enough memory for a reference tile".into()))?;
data.resize(n, 0.0);
Ok(data)
}
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
return Err(NetError::Invalid("the tile does not fit the network".into()));
}
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
let input = input.to_vec();
let mut total = input.len();
let mut tensors = vec![T { channels: net.in_channels, side: tile, data: input }];
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
};
@@ -27,7 +57,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
let side = tensors[a].side;
let pad = (c.k - 1) / 2;
let mut data = vec![0f32; c.cout * side * side];
let mut data = tensor(c.cout, side, &mut total)?;
// the input channels in order: the first source's, then the second's
let planes: Vec<&[f32]> =
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
@@ -56,7 +86,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
let a = get(&tensors, c.src)?;
let side = tensors[a].side;
let out_side = 2 * side;
let mut data = vec![0f32; c.cout * out_side * out_side];
let mut data = tensor(c.cout, out_side, &mut total)?;
for co in 0..c.cout {
for y in 0..side {
for x in 0..side {
@@ -77,7 +107,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
Op::MaxPool2 { src } => {
let a = get(&tensors, *src)?;
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
let mut data = vec![0f32; channels * side * side];
let mut data = tensor(channels, side, &mut total)?;
for ch in 0..channels {
for y in 0..side {
for x in 0..side {
@@ -92,7 +122,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
let a = get(&tensors, *src)?;
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
let out_side = 2 * side;
let mut data = vec![0f32; channels * out_side * out_side];
let mut data = tensor(channels, out_side, &mut total)?;
for ch in 0..channels {
for y in 0..side {
for x in 0..side {
@@ -131,3 +161,39 @@ impl TileRunner for ReferenceRunner {
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::ConvT2;
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
}
/// A graph that fits the final-shape check while zooming its intermediates out and back: 256 `ConvT2`s double
/// the sides (256 tiers), 255 `MaxPool`s halve them back to the layer above the input.
fn hostile() -> Net {
let mut ops = Vec::new();
let mut last = 0usize;
for _ in 0..256 {
ops.push(convt(last, 1, 1));
last += 1;
}
for _ in 0..255 {
ops.push(Op::MaxPool2 { src: last });
last += 1;
}
Net::new(1, ops).unwrap()
}
/// The zoomed-out layers' memory was never reserved: either one tensor or the retained whole (the two bounds
/// share the cap) says why the run stops.
#[test]
fn a_graph_that_zooms_its_intermediates_out_and_back_is_refused_before_their_memory_is_reserved() {
assert!(matches!(
run(&hostile(), 1, &[1.0]),
Err(NetError::Invalid(why)) if why.contains("too large") || why.contains("too much working memory")
));
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ thiserror = { workspace = true }
log = { workspace = true }
ab_glyph = { workspace = true }
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
harfrust = { version = "0.12", default-features = false, features = ["std"] }
harfrust = { version = "0.13", default-features = false, features = ["std"] }
unicode-segmentation = "1.13"
lightcraft-segment = { workspace = true, optional = true }
+1
View File
@@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("ILCE-7RM5", include_str!("../../../assets/camera-profiles/ILCE-7RM5.json")),
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
+122
View File
@@ -0,0 +1,122 @@
//! Caps on the container's metadata boxes, counted before heic-rs touches the file.
//!
//! heic-rs 0.1.4 (pinned `=0.1.4`) has no aggregate work budget inside its container parsing:
//! every `ipma` association entry rescans the vector of already-seen item ids (quadratic in
//! the number of entries), and an `iloc` extent using construction method 2 reconstructs its
//! source item afresh for every extent that references it, with no shared cache. Legitimate
//! phone-photo metadata never comes near these caps — real corpora keep both well under a few
//! kilobytes — so anything above them is hostile or corrupt and is refused before the
//! countable CPU work happens.
//!
//! [`depth_boxes`] stops silently at structural blips: heic-rs's own parser reports those
//! better than this walk could, and an `ipma` box past the cap is caught wherever it parses.
use heic_rs::boxes::BoxIter;
use crate::Error;
/// The largest `ipma` box (item ↔ property associations) payload accepted, 64 KiB. An entry
/// costs at least 3 bytes, so this bounds heic-rs's per-entry rescan of earlier item ids to
/// ~21 000 entries ≈ 220 million comparisons: long enough to be annoying, never minutes.
const MAX_IPMA_BYTES: usize = 64 << 10;
/// The largest `iloc` box (item locations) payload accepted, 64 KiB. An extent costs at least
/// 12 bytes, so this bounds both the parse itself and how many extents a
/// construction-method-2 reconstruction chain can visit.
const MAX_ILOC_BYTES: usize = 64 << 10;
/// Falls through unless the container declares more `ipma` or `iloc` payload than a
/// legitimate phone photo ever does.
pub fn cap(bytes: &[u8]) -> Result<(), Error> {
let mut c = Census::default();
depth_boxes(&mut c, BoxIter::new(bytes), 0);
if c.ipma > MAX_IPMA_BYTES {
return Err(Error::Limit(format!("the image carries {} bytes of property associations, over the {MAX_IPMA_BYTES}-byte cap", c.ipma)));
}
if c.iloc > MAX_ILOC_BYTES {
return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc)));
}
Ok(())
}
/// The metadata-box payload sizes seen so far.
#[derive(Default)]
struct Census {
ipma: usize,
iloc: usize,
}
/// Walks boxes, adding the payload sizes of those named in [`Census`].
///
/// Only `meta` and `iprp` (the two nesting boxes the metadata is built from) are descended
/// into, no deeper than that depth, so a nested-box trick can't hide a box from the count.
fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) {
if depth > 3 {
return;
}
for b in boxes {
// A structural error stops the walk; `heic_rs::probe` reports it properly afterwards.
let Ok(b) = b else { return };
if b.is(b"ipma") {
c.ipma = c.ipma.saturating_add(b.payload.len());
} else if b.is(b"iloc") {
c.iloc = c.iloc.saturating_add(b.payload.len());
} else if b.is(b"meta") {
// `meta` is a FullBox: version and flags sit between its header and its children.
if b.payload.len() >= 4 {
depth_boxes(c, BoxIter::new(&b.payload[4..]), depth + 1);
}
} else if b.is(b"iprp") {
depth_boxes(c, b.children(), depth + 1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Assembles ftyp + meta(version/flags + iprp(ipco, ipma)) by hand; only the structure
/// the census walks matters, no item is ever defined.
fn file_with(ipma_payload: &[u8]) -> Vec<u8> {
let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec();
let mut iprp = Vec::new();
iprp.extend(box_of(b"ipco", b""));
iprp.extend(box_of(b"ipma", ipma_payload));
let mut meta = vec![0u8, 0, 0, 0]; // version 0, flags 0
meta.extend(box_of(b"iprp", &iprp));
out.extend(box_of(b"meta", &meta));
out
}
fn box_of(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + body.len());
let size = u32::try_from(8 + body.len()).expect("test box fits in u32");
out.extend_from_slice(&size.to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(body);
out
}
#[test]
fn an_oversized_ipma_is_refused() {
// 40 000 entries of 6 bytes: 240 KiB of ipma, far over the cap, and quadratic if
// parsed (each entry rescans the earlier item ids in heic-rs 0.1.4).
let mut ipma = Vec::new();
for _ in 0..40_000u32 {
ipma.extend_from_slice(&[0u8, 0, 0, 0, 0, 0]);
}
let bytes = file_with(&ipma);
let r = crate::probe(&bytes).map(|_| ());
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}");
}
#[test]
fn a_undersized_ipma_is_untouched_by_the_caps() {
// The same shape with a normal ipma (two entries): the caps pass, and whatever error
// follows must not come from them.
let bytes = file_with(&[0, 0, 4, 1, 1, 2, 0, 0]);
let r = crate::probe(&bytes);
assert!(!matches!(r, Err(Error::Limit(ref m)) if m.contains("cap")), "{r:?}");
}
}
+108 -9
View File
@@ -19,6 +19,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod census;
mod orient;
/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`).
#[cfg(any(test, feature = "testdata"))]
@@ -149,20 +150,50 @@ pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
}
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
let info = heic_rs::probe(bytes).map_err(|e| match e {
// No `meta` box: no still image, only a `moov` image sequence (a video track).
census::cap(bytes)?;
// No `meta` box: no still image, only a `moov` image sequence (a video track).
let open_error = |e| match e {
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE),
e => err(e),
})?;
let ctx = Context::open(bytes).map_err(err)?;
};
let ctx = Context::open(bytes).map_err(open_error)?;
let id = ctx.meta.primary;
let coded = match ctx.grid(id).map_err(err)? {
// heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other
// items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items
// multiplies at every level while each result stays small. Count those visits from the
// `iloc` table before reconstructing anything: the primary item first (`heic_rs::probe` and
// `Context::grid` both rebuild its descriptor), then its alpha plane, then every tile
// reference, repeats included.
let mut visits = 0_usize;
item_visits(&ctx, id, 0, &mut visits)?;
let info = heic_rs::probe(bytes).map_err(open_error)?;
if let Some(alpha) = ctx.alpha_item(id).map_err(err)? {
item_visits(&ctx, alpha, 0, &mut visits)?;
}
let mut coded = match ctx.grid(id).map_err(err)? {
Some((_, tiles)) => tiles,
None => vec![id],
};
for &item in coded.iter().filter(|&&item| item != id) {
item_visits(&ctx, item, 0, &mut visits)?;
}
// A `dimg` listing may name the same tile more than once, and reconstructable items are
// moreover rebuilt for every extent that references them in heic-rs 0.1.4. For the size
// check each unique item counts once: the total reconstruction (all of it assembled from
// `bytes` by heic-rs) is held below one copy of the file, above which a repeat or chain
// wants more bytes than exist. Order doesn't matter here: this only checks that every
// tile's bytes lie in the file.
coded.sort_unstable();
coded.dedup();
let mut reconstructed = 0_usize;
for item in coded {
// Borrowed from `bytes` unless split across extents: cheap.
ctx.item_data(item).map_err(err)?;
reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len());
if reconstructed > bytes.len() {
return Err(Error::Limit(format!(
"the container holds {reconstructed} bytes of item data, over the {} bytes of the file itself",
bytes.len()
)));
}
}
Ok(Info {
width: info.width,
@@ -174,6 +205,36 @@ fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
})
}
/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real
/// files need one per tile (plus a few for the rare derived item).
const MAX_ITEM_VISITS: usize = 4096;
/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself).
const MAX_ITEM_CHAIN: u32 = 8;
/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built
/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep.
fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> {
use heic_rs::meta::iloc::Construction;
if depth > MAX_ITEM_CHAIN {
return Err(Error::Limit(format!("items built from items nest deeper than {MAX_ITEM_CHAIN} levels")));
}
let Some(loc) = ctx.meta.location(id) else { return Ok(()) };
*visits = visits.saturating_add(loc.extents.len().max(1));
if *visits > MAX_ITEM_VISITS {
return Err(Error::Limit(format!("reconstructing the image takes over {MAX_ITEM_VISITS} item extent reads")));
}
if loc.construction == Construction::Item {
for e in &loc.extents {
if let Ok(source) = u32::try_from(e.index)
&& source != id
{
item_visits(ctx, source, depth + 1, visits)?;
}
}
}
Ok(())
}
/// Decodes the primary image, with its metadata.
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
guarded(|| decode_unguarded(bytes, options))
@@ -188,7 +249,13 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
let ctx = Context::open(bytes).map_err(err)?;
let primary = ctx.meta.primary;
let mut best: Option<(u32, u32)> = None;
// A thumbnail whose own reconstruction would cost too much is not a candidate; the visits
// of all the candidates share one budget.
let mut candidate_visits = 0_usize;
for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) {
if item_visits(&ctx, r.from, 0, &mut candidate_visits).is_err() {
continue;
}
let Ok(p) = ctx.props(r.from) else { continue };
let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue };
let edge = w.max(h);
@@ -197,6 +264,15 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
}
}
let Some((id, _)) = best else { return Ok(None) };
// The same pre-scan as `probe`: the thumbnail's own descriptor and every tile reference
// are counted before heic-rs rebuilds any of them.
let mut visits = 0_usize;
item_visits(&ctx, id, 0, &mut visits)?;
if let Some((_, tiles)) = ctx.grid(id).map_err(err)? {
for &tile in &tiles {
item_visits(&ctx, tile, 0, &mut visits)?;
}
}
let mut decoded = decode_item(&ctx, id, options, false)?;
if decoded.icc.is_none() {
decoded.icc = item_icc(&ctx, primary);
@@ -232,6 +308,13 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
/// `with_alpha` and the item has an alpha auxiliary image.
fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result<Decoded, Error> {
let p = ctx.props(id).map_err(err)?;
// heic-rs 0.1.4 keeps every associated transform and its [`orient::apply`] allocates the
// full picture again for each: a file could chain the repetition and multiply decode CPU
// without changing any checked size. Real files carry at most one `clap`, `irot` and
// `imir`, so refuse anything approaching [`MAX_TRANSFORMS`] (CodeRabbit #44).
if p.transforms.len() > MAX_TRANSFORMS {
return Err(Error::Limit(format!("the image carries {} transform properties, over the {MAX_TRANSFORMS}-property cap", p.transforms.len())));
}
let limit = options.max_pixels;
// The declared sizes are refused before anything is decoded or allocated.
let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?;
@@ -245,6 +328,14 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range };
let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None };
let (w, h) = planes.size();
// A grid's canvas dimensions come from its payload, not the checked `ispe` (CodeRabbit
// #48): check the real one, before [`ycc::to_rgb16`] allocates for it.
match (u32::try_from(w), u32::try_from(h)) {
(Ok(wu), Ok(hu)) => {
heic_rs::image::check_pixels(wu, hu, limit).map_err(err)?;
}
_ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))),
}
let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest };
let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode)
.ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?;
@@ -281,6 +372,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
const GRID_BUDGET: u64 = 2;
/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at
/// most one of each, so anything approaching this is a reordered or hostile association list.
const MAX_TRANSFORMS: usize = 12;
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
struct Coded<'a> {
frames: Vec<Frame>,
@@ -370,8 +465,12 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
/// metadata item never fails a decode whose pixels came out fine.
fn metadata(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
let id = ctx.meta.primary;
let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec);
let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
// Metadata is best effort, so an item that would cost too much to rebuild is skipped, not an error.
let mut visits = 0_usize;
let cheap = |item: Option<u32>, visits: &mut usize| item.is_none_or(|item| item_visits(ctx, item, 0, visits).is_ok());
let exif = if cheap(ctx.exif_item(id), &mut visits) { ctx.exif(id).ok().flatten().map(<[u8]>::to_vec) } else { None };
let xmp_item = ctx.xmp_item(id).filter(|&x| cheap(Some(x), &mut visits));
let xmp = xmp_item.and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(),
Cow::Owned(b) => String::from_utf8(b).ok(),
});
+13
View File
@@ -185,3 +185,16 @@ fn truncated_and_corrupted_files_are_errors_not_panics() {
let _ = decode_thumbnail(&b, 1, &Options::default());
}
}
/// CodeRabbit #44: heic-rs keeps every associated transform and each one re-allocates the
/// whole picture, so 13 of them are over the [`crate::MAX_TRANSFORMS`] cap and refused.
#[test]
fn too_many_transform_properties_are_refused() {
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 13).collect(), ..Spec::new(32, 32, &grey) });
let r = decode(&bytes, &Options::default());
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("transform")), "{r:?}");
// At the cap they're not: 12 identity rotations decode normally.
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 12).collect(), ..Spec::new(32, 32, &grey) });
let d = decode(&bytes, &Options::default()).unwrap();
assert_eq!((d.width, d.height), (32, 32));
}
+26
View File
@@ -161,6 +161,13 @@ pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampli
if !(8..=16).contains(&bd) || w == 0 || h == 0 {
return None;
}
// The default caller limit is 2²8 pixels, at most four channels per pixel, so the largest
// legitimate `w·h·ch` is 2²30 samples. Anything more is a malformed or hostile canvas the
// decode path would have refused before calling here (CodeRabbit #48): refuse rather than
// ask for a multi-gigabuffer (or worse, over usize).
if w.checked_mul(h)?.checked_mul(ch)? > 1 << 30 {
return None;
}
let max = ((1u32 << bd) - 1) as f32;
let half = (1u32 << (bd - 1)) as f32;
let scale = (1u32 << (bd - 8)) as f32;
@@ -323,4 +330,23 @@ mod tests {
.is_none()
);
}
/// CodeRabbit #48: a plane claiming 500 000 × 500 000 pixels must be refused without ever
/// allocating for it (before the cap this vec was never built; upper-bounding allocation
/// would have been a 7.5-gibabuffer or a panic instead of an error).
#[test]
fn an_oversized_canvas_is_refused_without_allocation() {
let f = Frame {
width: 500_000,
height: 500_000,
bit_depth: 8,
chroma: ChromaFormat::Yuv420,
y: vec![128],
cb: vec![128; 4],
cr: vec![128; 4],
y_stride: 0,
c_stride: 0,
};
assert!(to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).is_none());
}
}
+2
View File
@@ -34,6 +34,8 @@ pub use pano::{PanoOptions, PanoResult, Projection, stitch};
pub enum MergeError {
#[error("{0} photo(s) selected; at least {1} are needed")]
TooFew(usize, usize),
#[error("{0} photo(s) selected; a stitch of more than {1} is refused: matching every pair would take far too long")]
TooMany(usize, usize),
#[error("{0}")]
Mismatch(String),
#[error("could not read a photo: {0}")]
+6 -4
View File
@@ -61,10 +61,12 @@ pub struct PairMatch {
/// Match every pair of images. `feats[k]` are in work coordinates scaled by `scales[k]`.
pub fn match_pairs(feats: &[Features], scales: &[f64]) -> Vec<PairMatch> {
let n = feats.len();
let pairs: Vec<(usize, usize)> = (0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect();
let mut out: Vec<PairMatch> = pairs
.par_iter()
.filter_map(|&(i, j)| {
// the pairs are generated lazily: materialising all n(n−1)/2 up front is quadratic memory, including
// when every comparison comes up empty (no usable features)
let mut out: Vec<PairMatch> = (0..n)
.into_par_iter()
.flat_map_iter(|i| ((i + 1)..n).map(move |j| (i, j)))
.filter_map(|(i, j)| {
let m = features::match_features(&feats[i], &feats[j], 0.8);
if m.len() < 12 {
return None;
+8
View File
@@ -53,6 +53,11 @@ impl Default for PanoOptions {
/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down.
pub const MAX_MEGAPIXELS: f64 = 512.0;
/// Most frames one stitch accepts: pair matching compares every pair of frames (n(n−1)/2 runs), so an
/// unbounded frame count is quadratic CPU as well as quadratic memory while the frames load. A panorama
/// with more than [`MAX_FRAMES`] shots is refused with an error, not attempted.
pub const MAX_FRAMES: usize = 256;
/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down.
pub const MAX_EDGE: f64 = 65_000.0;
@@ -224,6 +229,9 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
if n < 2 {
return Err(MergeError::TooFew(n, 2));
}
if n > MAX_FRAMES {
return Err(MergeError::TooMany(n, MAX_FRAMES));
}
check(progress, 0.01, "Finding features")?;
let (cams, rms_before, rms) = register(&frames, progress)?;
let used: Vec<usize> = (0..n).filter(|&i| cams[i].is_some()).collect();
+12
View File
@@ -246,6 +246,18 @@ fn unrelated_photos_do_not_stitch() {
assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err());
}
/// Pair matching compares every pair of frames before anything is known about them: an unbounded
/// photo count is quadratic CPU and memory (issue #1519). More than `MAX_FRAMES` is refused at once,
/// before any frame is worked on.
#[test]
fn a_stitch_of_more_than_the_frame_cap_is_refused_up_front() {
let frames: Vec<Frame> = (0..lightcraft_merge::pano::MAX_FRAMES + 1)
.map(|i| Frame::from_linear_rec2020(Rgb32f::from_fn(8, 8, |_, _| [(i % 255) as f32 / 255.0, 0.0, 0.0]), None))
.collect();
let err = stitch(frames, &PanoOptions::default(), &no_progress).unwrap_err();
assert!(err.to_string().contains("refused"), "the frame cap errors, it does not begin the quadratic match: {err}");
}
/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their
/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down,
/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail.
+10 -2
View File
@@ -1,7 +1,7 @@
//! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics).
use crate::{DateTime, Flash, Gps, Metadata, Orientation};
use lightcraft_tiff::{Ifd, Tiff, TiffError, tags as t};
use lightcraft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t};
/// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks.
pub fn strip_exif_header(b: &[u8]) -> &[u8] {
@@ -16,8 +16,16 @@ pub fn read_exif(bytes: &[u8]) -> Metadata {
/// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0.
pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> {
try_read_exif_with(bytes, &ParseOptions::default())
}
/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular).
/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually
/// loaded, the rest is zeros — must not size its allocations by the logical file length, which
/// unrelated bytes of the file can inflate at no cost to the attacker.
pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result<Metadata, TiffError> {
let data = strip_exif_header(bytes);
let tiff = Tiff::parse(data)?;
let tiff = Tiff::parse_with(data, opts)?;
let mut m = from_tiff(&tiff);
// Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif)
if m.lens_model.is_none() {
+9 -1
View File
@@ -171,7 +171,15 @@ fn tiff(r: &mut Reader, head: Vec<u8>) -> io::Result<Option<FocusPoint>> {
loaded.insert(0, head.len() as u64);
for _ in 0..MAX_ROUNDS {
match plan(&buf, &loaded) {
Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)),
Step::Ready => {
// The decoder's allocation budget follows the bytes actually fetched, not the logical
// file length: only the loaded ranges are data, and an unrelated entry that claims a
// huge value must be cut by what leaving it cost, not by what the buffer claims to hold.
// The same 8 × / + 1 MiB shape the general TIFF reader uses, over `reader.bytes` instead.
let opts =
lightcraft_tiff::ParseOptions { value_budget: Some(r.bytes.saturating_mul(8).saturating_add(1 << 20)), ..Default::default() };
return Ok(crate::try_read_exif_with(&buf, &opts).ok().and_then(|m| m.focus));
}
Step::Nothing => return Ok(None),
Step::Need(ranges) => {
for (a, b) in ranges {
+1 -1
View File
@@ -27,7 +27,7 @@ mod xmp_merge;
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif};
pub use focus::FocusPoint;
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
+63 -10
View File
@@ -154,6 +154,11 @@ const MAX_NODES: usize = 200_000;
// Expanding a long namespace URI on many short names must not amplify a small packet without bound.
pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024;
/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor
/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise
/// retains its prefix once per property: node limits alone cannot bound that duplication.
const MAX_KEY_BYTES: usize = 1 << 20;
/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque.
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result<String, XmpError> {
canonical(qname, scopes, opaque_budget)
@@ -332,18 +337,29 @@ fn is_rdf_meta_attr(k: &str) -> bool {
matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns")
}
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>) {
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
for (k, v) in &d.attrs {
if !is_rdf_meta_attr(k) {
if !is_rdf_meta_attr(k) && take_key_bytes(&format!("{prefix}{k}"), budget) {
props.entry(format!("{prefix}{k}")).or_default().push(v.clone());
}
}
for c in &d.children {
collect_property(c, prefix, props);
collect_property(c, prefix, props, budget);
}
}
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>) {
/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the
/// property is then left out, like other elements a limit has cut).
fn take_key_bytes(key: &str, budget: &mut usize) -> bool {
let n = key.len();
if *budget < n {
return false;
}
*budget -= n;
true
}
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
let key = format!("{prefix}{}", p.name);
if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
let mut items: Vec<(bool, String)> = arr
@@ -357,29 +373,36 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str
if arr.name == "rdf:Alt" {
items.sort_by_key(|(default, _)| !*default);
}
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
if take_key_bytes(&key, budget) {
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
}
return;
}
let struct_prefix = format!("{key}/");
if p.attr("rdf:parseType") == Some("Resource") {
for c in &p.children {
collect_property(c, &struct_prefix, props);
collect_property(c, &struct_prefix, props, budget);
}
return;
}
if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") {
collect_description(d, &struct_prefix, props);
collect_description(d, &struct_prefix, props, budget);
return;
}
let field_attrs: Vec<&(String, String)> = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect();
if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() {
for (k, v) in field_attrs {
props.entry(format!("{struct_prefix}{k}")).or_default().push(v.clone());
let full = format!("{struct_prefix}{k}");
if take_key_bytes(&full, budget) {
props.entry(full).or_default().push(v.clone());
}
}
return;
}
let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string());
props.entry(key).or_default().push(v);
if take_key_bytes(&key, budget) {
props.entry(key).or_default().push(v);
}
}
fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
@@ -497,8 +520,10 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
find_descriptions(&tree, &mut descs);
let mut props = BTreeMap::new();
let mut values = BTreeMap::new();
// One budget for the whole document: several descriptions cannot each spend a fresh one.
let mut key_budget = MAX_KEY_BYTES;
for d in descs {
collect_description(d, "", &mut props);
collect_description(d, "", &mut props, &mut key_budget);
if let XmpValue::Struct(m) = node_struct(d) {
values.extend(m);
}
@@ -1171,4 +1196,32 @@ mod tests {
assert_eq!(entity("#x41"), Some('A'));
assert_eq!(entity("#9"), Some('\t'));
}
/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child
/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it
/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text).
#[test]
fn flattened_keys_stay_within_the_cumulative_budget() {
let level = "n".repeat(200);
let mut doc = String::from(
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:lc=\"https://lightcraft.example/lc/\"><rdf:RDF><rdf:Description><lc:root rdf:parseType=\"Resource\">",
);
for _ in 0..4 {
doc.push_str(&format!("<lc:{level} rdf:parseType=\"Resource\">"));
}
// each leaf has a name of its own, so each one adds a key
for i in 0..3000 {
doc.push_str(&format!("<lc:leaf{i} lc:att=\"v\"/>"));
}
doc.push_str("</lc:root>");
for _ in 0..4 {
doc.push_str(&format!("</lc:{level}>"));
}
doc.push_str("</rdf:Description></rdf:RDF></x:xmpmeta>");
let d = parse_xmp(&doc).unwrap();
let total: usize = d.properties.keys().map(String::len).sum();
assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget");
assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent");
assert!(!d.properties.is_empty(), "properties parsed before the budget are kept");
}
}
+55 -2
View File
@@ -94,6 +94,17 @@ pub const SOFTWARE: &str = "LightCraft DNG 2";
/// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs.
const DEFLATE_LEVEL: u8 = 1;
/// Largest Deflate tile edge the writer pads to (`tile` is caller-supplied in [`DngCompression::Deflate`]):
/// square-tile buffers grow with the square, so an unclamped edge lets a request pad a tiny valid image into
/// a multi-gigabyte buffer and billions of edge-replicate iterations. 2048² samples (≤ 24 MB of f32) is far
/// above the 256 default and every real tile this writer makes.
const MAX_DEFLATE_TILE: u32 = 2048;
/// `tile` aligned down to a multiple of 16 and clamped to [`MAX_DEFLATE_TILE`].
fn deflate_tile(tile: u32) -> usize {
(tile.max(16) & !15).min(MAX_DEFLATE_TILE) as usize
}
/// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept).
pub fn f32_to_f16(v: f32) -> u16 {
let b = v.to_bits();
@@ -318,7 +329,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
match (&raw.data, opts.compression) {
(RawData::F32(v), DngCompression::Deflate { tile, half }) => {
let tile = (tile.max(16) & !15) as usize;
let tile = deflate_tile(tile);
let bp = if half { 2 } else { 4 };
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp]));
ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp]));
@@ -417,7 +428,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
ifd.set_image(ImageData::Strips { rows_per_strip: rows as u32, strips });
}
DngCompression::Deflate { tile, .. } => {
let tile = (tile.max(16) & !15) as usize;
let tile = deflate_tile(tile);
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16; cpp]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE]));
ifd.set(t::PREDICTOR, Value::Short(vec![2]));
@@ -546,3 +557,45 @@ pub fn write_rendered_dng(
let order = ByteOrder::Little;
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BlackLevel, OpcodeLists, RawData, RawFormat, RawImage, Rect};
/// Deflate tiles of `tile × tile` pad into a square buffer that grows with the square: a
/// caller-supplied edge of `u32::MAX` would make the writer pad an 8 × 8 image into billions of
/// edge-replicate iterations and a multi-gigabyte buffer. The clamp keeps the bounded shape — same
/// file as requesting the cap exactly, and a DNG either way.
#[test]
fn a_hostile_deflate_tile_edge_is_clamped() {
let (w, h) = (8usize, 8usize);
let full = Rect::new(0, 0, w, h);
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::U16((0..64).map(|i| i as u16 * 129).collect()),
cfa: None,
bits: 12,
black: BlackLevel::uniform(0.0),
white: vec![4095.0],
active_area: full,
crop: full,
orientation: lightcraft_geom::Orientation::Normal,
color: crate::ColorData::default(),
wb_multipliers: None,
linearized: false,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: Default::default(),
lens_metadata: Default::default(),
};
let opts = |tile: u32| DngWriteOptions { compression: DngCompression::Deflate { tile, half: false }, ..DngWriteOptions::default() };
let hostile = write_dng(&raw, &opts(u32::MAX)).expect("the clamped tile writes");
assert_eq!(hostile, write_dng(&raw, &opts(2048)).expect("the cap itself writes"), "u32::MAX is clamped to the cap");
assert_ne!(hostile, write_dng(&raw, &opts(256)).expect("a normal tile writes"), "the clamp is not a no-op");
assert_eq!(crate::probe(&hostile), Some(RawFormat::Dng), "the clamped output is still a DNG");
}
}
+43 -2
View File
@@ -656,18 +656,38 @@ fn fix_pixels(buf: &mut [f32], w: usize, h: usize, cpp: usize, cfa: Option<&Cfa>
}
}
/// Most full-image passes of one `OpcodeList3`: every warp record is a full-image allocation and resample
/// pass and every bad-pixel record a full flatten-and-rebuild, and a hostile record may repeat either
/// thousands of times. Real DNGs carry at most a couple; passes past the cap are skipped.
const MAX_LIST3_PASSES: usize = 8;
/// Apply `OpcodeList3` (on the demosaiced RGB image, values normalised to [0, 1]).
pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) {
if list.is_empty() {
return;
}
let (w, h) = (img.width, img.height);
let mut passes = 0usize;
for op in applied_ops(list) {
match op {
Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center),
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal),
Opcode::WarpRectilinear { planes, center } => {
if passes < MAX_LIST3_PASSES {
passes += 1;
*img = warp_rectilinear(img, planes, *center);
}
}
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
if passes < MAX_LIST3_PASSES {
passes += 1;
*img = warp_rectilinear2(img, planes, *center, *reciprocal);
}
}
Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {}
other => {
if passes >= MAX_LIST3_PASSES {
continue;
}
passes += 1;
let mut flat: Vec<f32> = img.data.iter().flat_map(|p| *p).collect();
apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0);
for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) {
@@ -765,6 +785,27 @@ pub(crate) mod tests {
a.iter().flat_map(|v| v.to_be_bytes()).collect()
}
/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them
/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must
/// still apply, exactly, so the same image comes out.
#[test]
fn list3_pass_cap_skips_repeated_records() {
let mut img = Rgb32f::new(8, 6);
for (i, px) in img.data.iter_mut().enumerate() {
*px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32];
}
let orig: Vec<[f32; 3]> = img.data.clone();
let list: Vec<Opcode> =
(0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect();
apply_list3(&list, &mut img);
assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest");
// a real warp below the cap is still applied
let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }];
apply_list3(&one, &mut img);
assert_ne!(img.data, orig, "a genuine warp record is not skipped");
}
#[test]
fn parse_and_apply_area_ops() {
let (w, h) = (4usize, 3usize);
+62 -10
View File
@@ -103,8 +103,6 @@ fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> {
let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?])));
Some((be16(7)?, be16(5)?, at(9)?))
}
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
fn is_jxl(b: &[u8]) -> bool {
b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a])
}
@@ -128,20 +126,17 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
}
if matches!(ifd.u16(t::COMPRESSION), Some(6) | Some(7) | Some(34892))
&& let Ok(info) = ifd.image()
&& let Some(chunk) = info.single_chunk(data.len() as u64)
&& let Some(s) = chunk_bytes(data, &chunk)
{
let chunks = info.chunks(data.len() as u64);
if chunks.len() == 1
&& let Some(s) = chunk_bytes(data, &chunks[0])
{
out.push(s);
}
out.push(s);
}
// a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk
if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL)
&& matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB))
&& let Ok(info) = ifd.image()
&& let [chunk] = info.chunks(data.len() as u64).as_slice()
&& let Some(s) = chunk_bytes(data, chunk)
&& let Some(chunk) = info.single_chunk(data.len() as u64)
&& let Some(s) = chunk_bytes(data, &chunk)
&& is_jxl(s)
{
out.push(s);
@@ -397,8 +392,14 @@ fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
/// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0
/// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller
/// `THMB` box has the same shape. Returns the larger valid one.
/// Marker-walk budget of the CR3 fallback: candidates can overlap (each claiming the rest of the file) and
/// [`is_dct_jpeg`] walks each marker by marker, so walking every candidate would be quadratic in file size.
/// The `THMB`/`PRVW` previews this finds are far below the cap together; once it is spent the scan stops.
const CR3_MAX_WALK: usize = 8 << 20;
fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
let mut best: Option<&[u8]> = None;
let mut walked = 0usize;
for tag in [b"PRVW", b"THMB"] {
let mut from = 0;
while let Some(i) = bytes.get(from..).and_then(|s| s.windows(4).position(|w| w == tag)).map(|p| p + from) {
@@ -411,6 +412,11 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
continue;
}
let jpeg = &bytes[j..j + len];
// One that doesn't fit the budget left is skipped; a smaller one later may still fit.
if walked + jpeg.len() > CR3_MAX_WALK {
continue;
}
walked += jpeg.len();
if is_dct_jpeg(jpeg) {
if best.is_none_or(|b| b.len() < jpeg.len()) {
best = Some(jpeg);
@@ -440,6 +446,52 @@ mod tests {
j
}
/// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first:
/// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their
/// descriptor vector, and of course finds no preview in it.
#[test]
fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() {
let mut ifd = IfdBuilder::new();
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000]));
ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1]));
ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect()));
let file = TiffWriter::default().write(&[ifd]).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(embedded_preview(&file)));
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None));
}
/// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so
/// a walk jogs past the next header and reaches the end of the file, making every one of the 9000
/// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here
/// is a JPEG (no frame header), so the answer is `None` either way.
#[test]
fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() {
// one 256-byte block per candidate, all marker-aligned so walks chain through later blocks:
// 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length
// (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of
// 4-byte markers (`ff 01 00 02`).
let blocks = 9000usize;
let total = blocks * 256;
let mut f = Vec::with_capacity(total);
for k in 0..blocks {
let base = k * 256;
f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes());
f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]);
f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free
for _ in 0..48 {
f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]);
}
}
assert_eq!(f.len(), total);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len())));
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG");
}
/// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the
/// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than
/// the JPEG holds the way a padded container does.
+94 -5
View File
@@ -35,6 +35,12 @@ const MAX_ENTRIES: u64 = 8192;
const MAX_BOXES: usize = 4096;
/// Child IFDs listed by one `SubIFDs` entry.
const MAX_CHILDREN: u64 = 4096;
/// Pending child-IFD offsets the walker may queue at once: no more than it can ever visit
/// (the same [`MAX_IFDS`] the seen set is held to, deduplicated as they are popped), so an alley
/// of hostile `SubIFDs` entries can't grow the queue into the tens of millions of pointers.
/// The plan never guesses: the walk refuses and the plan reports the whole file when it would go
/// past the cap.
const MAX_PENDING: usize = MAX_IFDS;
/// The RAF raw container's pointer to its raw IFD.
const RAF_RAW_IFD: u16 = 0xf000;
@@ -308,6 +314,13 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
};
let mut stack = vec![first];
let mut seen = HashSet::new();
// Every offset ever queued: the pending cap counts distinct IFDs, so a thousand references to
// one child cost one visit, not a thousand.
let mut queued = HashSet::from([first]);
// Every IFD table and out-of-line value, collected and merged once at the end: an insert
// per entry would scan the ranges of every entry before it — quadratic in the metadata
// this walker collects.
let mut spans: Vec<(u64, u64)> = Vec::new();
while let Some(off) = stack.pop() {
if off == 0 || off >= len || seen.len() >= MAX_IFDS || !seen.insert(off) {
continue;
@@ -329,7 +342,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
if !w.want(at(off), at(end)) {
continue;
}
meta.insert(at(off), at(end));
spans.push((at(off), at(end)));
for i in 0..n_read {
let p = table + i * entry;
let Ok(pu) = usize::try_from(p) else { continue };
@@ -348,7 +361,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
};
let Some(value_end) = start.checked_add(total).filter(|&e| e <= len) else { continue };
if total > inline {
meta.insert(at(start), at(value_end));
spans.push((at(start), at(value_end)));
}
let child = matches!(tag, t::SUB_IFDS | t::EXIF_IFD | t::GPS_IFD | t::INTEROP_IFD) || extra.contains(&tag);
let locates = child || matches!(tag, t::STRIP_OFFSETS | t::STRIP_BYTE_COUNTS | t::TILE_OFFSETS | t::TILE_BYTE_COUNTS);
@@ -359,14 +372,24 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
let take = if tag == t::SUB_IFDS { count.min(MAX_CHILDREN) } else { count.min(1) };
for k in 0..take {
if let Some(v) = unsigned(data, order, typ, start + k * typ.size() as u64) {
if v == 0 || v >= len || !queued.insert(v) {
continue;
}
if stack.len() >= MAX_PENDING {
return Err("too many child IFDs queued to visit");
}
stack.push(v);
}
}
}
if complete && let Some(next) = offset_at(table_end) {
if complete && let Some(next) = offset_at(table_end).filter(|&next| next != 0 && next < len && queued.insert(next)) {
if stack.len() >= MAX_PENDING {
return Err("too many child IFDs queued to visit");
}
stack.push(next);
}
}
meta.extend(spans);
Ok(meta)
}
@@ -585,8 +608,74 @@ mod tests {
assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles"));
}
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is
/// built in one sort and merge, not one pass over all strips (or all holes) per strip.
/// An IFD whose `SubIFDs` entries would queue 33 million child pointers (8192 entries of
/// 4096 values apiece, all pointing at the same array) is refused at the walker's pending
/// cap instead of growing the queue — and the plan reports the whole file, never guessing.
#[test]
fn a_subifds_alley_is_refused_at_the_pending_cap() {
// First read everything, so the walk reaches the directory table itself.
let n = 8192u32;
let len = 8 + 2 + (n * 12 + 4 + 4 * 4096) as usize;
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend_from_slice(&(n as u16).to_le_bytes());
let mut entry = |tag: u16, typ: u16, count: u32, value: u32| {
tiff.extend_from_slice(&tag.to_le_bytes());
tiff.extend_from_slice(&typ.to_le_bytes());
tiff.extend_from_slice(&count.to_le_bytes());
tiff.extend_from_slice(&value.to_le_bytes());
};
// header (8) + count field (2) + the {n} numbers and pointer (4), then the value array
let values_at = (8 + 2u64 + u64::from(n) * 12 + 4) as u32;
for _ in 0..n {
entry(t::SUB_IFDS, 4, MAX_CHILDREN as u32, values_at);
}
tiff.extend_from_slice(&0u32.to_le_bytes());
tiff.extend((1..=4096u32).flat_map(u32::to_le_bytes));
let mut loaded = Ranges::new();
loaded.insert(0, len as u64);
let mut w = Want::new(&tiff, &loaded);
assert_eq!(walk_tiff(&mut w, 0, &tiff, &[]), Err("too many child IFDs queued to visit"));
}
/// A TIFF with many out-of-line values across 512 directories is planned in one sort and
/// merge, not one pass over all accumulated ranges per value (CodeRabbit #52: before, each
/// `Ranges::insert` copied every range found before it — the ~million disjoint values of the
/// fixture below ask for more copying than the timeout allows).
#[test]
fn many_out_of_line_values_plan_fast() {
let (ifds, values) = (512usize, 2048usize);
let ifd_len = 2 + values * 12 + 4;
let values_at = 8 + ifds * ifd_len;
let len = values_at + values * 8;
let mut tiff = vec![0u8; len];
tiff[..8].copy_from_slice(b"II*\0\x08\0\0\0");
for i in 0..ifds {
let at = 8 + i * ifd_len;
// {values} out-of-line strings (count 8 > the 4 inline bytes), then the next-IFD
// pointer; every value is a disjoint 8-byte range for the merge to handle.
tiff[at..at + 2].copy_from_slice(&(values as u16).to_le_bytes());
for k in 0..values {
let e = at + 2 + k * 12;
tiff[e..e + 2].copy_from_slice(&t::MAKE.to_le_bytes());
tiff[e + 2..e + 4].copy_from_slice(&2u16.to_le_bytes()); // ASCII: 8 bytes > inline — a span per entry
tiff[e + 4..e + 8].copy_from_slice(&8u32.to_le_bytes());
tiff[e + 8..e + 12].copy_from_slice(&(values_at as u32 + (k * 8) as u32).to_le_bytes());
}
let next = if i == ifds - 1 { 0u32 } else { (at + ifd_len) as u32 };
tiff[at + ifd_len - 4..at + ifd_len].copy_from_slice(&next.to_le_bytes());
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut loaded = Ranges::new();
loaded.insert(0, len as u64);
let _ = tx.send(header_plan(&tiff, &loaded));
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s");
}
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0:
/// the plan is built in one sort and merge, not one pass over all strips (or all holes) per
/// strip.
#[test]
fn many_strips_without_byte_counts_plan_fast() {
let n = 1_000_000u32;
+205 -36
View File
@@ -105,48 +105,93 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
let chunks = info.chunks(data.len() as u64);
let planar = info.planar == 2 && cpp > 1;
let ccpp = if planar { 1 } else { cpp };
let decoded: Vec<Result<(Chunk, ChunkPx)>> =
chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
// The chunks are decoded and copied into the output in groups whose combined nominal size stays within
// a few times the output buffer's: everything decoded at once would retain the whole image twice, and
// a hostile tile table's chosen padding could stretch that much further (issue #1517). One group
// still decodes in parallel; the grid order groups make, the copies stay disjoint.
// The tile grid decides how much is decoded, whatever the file's bytes: the nominal sizes of all
// the chunks stay within a few times the image (edge tiles pad it) before any is decoded.
let nominal = chunks.iter().fold(0usize, |sum, c| sum.saturating_add(chunk_samples(c, ccpp)));
if nominal > total.saturating_mul(4).saturating_add(1 << 24) {
return Err(RawError::Corrupt("the tile grid is much larger than the image".into()));
}
let group_samples = (total / 4).max(1 << 20);
let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] };
let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() };
for r in decoded {
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
let (c, px) = r?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (x0, y0) = (c.x as usize, c.y as usize);
if x0 >= w || y0 >= h {
continue;
let mut decode_from = 0usize;
while decode_from < chunks.len() {
let mut to = decode_from;
let mut taken = 0usize;
for (k, c) in chunks[decode_from..].iter().enumerate() {
let n = chunk_samples(c, ccpp);
if taken > 0 && taken.saturating_add(n) > group_samples {
break;
}
taken = taken.saturating_add(n);
to = decode_from + k + 1;
}
let copy_w = cw.min(w - x0);
let copy_h = ch.min(h - y0);
for y in 0..copy_h {
for x in 0..copy_w {
for s in 0..ccpp {
let src = (y * cw + x) * ccpp + s;
let dst_s = if planar { c.plane as usize } else { s };
if dst_s >= cpp {
continue;
}
let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s;
match &px {
ChunkPx::U16(v) => {
if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) {
*o = s;
}
let decoded: Vec<Result<(Chunk, ChunkPx)>> =
chunks[decode_from..to].par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
for r in decoded {
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
let (c, px) = r?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (x0, y0) = (c.x as usize, c.y as usize);
if x0 >= w || y0 >= h {
continue;
}
let copy_w = cw.min(w - x0);
let copy_h = ch.min(h - y0);
for y in 0..copy_h {
for x in 0..copy_w {
for s in 0..ccpp {
let src = (y * cw + x) * ccpp + s;
let dst_s = if planar { c.plane as usize } else { s };
if dst_s >= cpp {
continue;
}
ChunkPx::F32(v) => {
if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) {
*o = s;
let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s;
match &px {
ChunkPx::U16(v) => {
if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) {
*o = s;
}
}
ChunkPx::F32(v) => {
if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) {
*o = s;
}
}
}
}
}
}
}
decode_from = to;
}
Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) })
}
/// Bytes that `n` samples take in a row of an uncompressed or inflated chunk, laid out as
/// [`unpack_chunk`] reads them: whole bytes or 16-bit words, or 32-bit words for `Words32Msb`.
fn packed_row_bytes(n: u64, bits: u32, packing: Packing, float: bool) -> u64 {
if float {
return n.saturating_mul(u64::from(bits.div_ceil(8)));
}
let bit_len = n.saturating_mul(u64::from(bits));
match (bits, packing) {
(8, _) => n,
(16, _) | (_, Packing::Word16) => n.saturating_mul(2),
(_, Packing::Msb | Packing::Lsb) => bit_len.div_ceil(8),
(_, Packing::Words32Msb) => bit_len.div_ceil(32).saturating_mul(4),
}
}
/// A chunk's nominal decoded size in samples (per plane, as the decoder builds it).
fn chunk_samples(c: &Chunk, ccpp: usize) -> usize {
(c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp)
}
#[allow(clippy::too_many_arguments)]
fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?;
@@ -179,9 +224,13 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
}
comp::LOSSY_JPEG => {
// lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples
let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?;
let mut v = vec![0u16; n];
for y in 0..ch.min(jh) {
let (px, jw, jh, jc) = lossy_jpeg(src, cpp, MAX_JPEG_SAMPLES)?;
// the buffer holds the rows the decoded JPEG covers (the caller zero-fills the tile's
// remainder and reads only rows it fills): a tile-many-larger decoded JPEG does not
// get the nominal tile's full buffer (issue #1517)
let rows = ch.min(jh);
let mut v = vec![0u16; rows.max(1).saturating_mul(cw).saturating_mul(cpp)];
for y in 0..rows {
for x in 0..cw.min(jw) {
for k in 0..cpp {
v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16;
@@ -191,13 +240,28 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
Ok(ChunkPx::U16(v))
}
comp::ADOBE_DEFLATE | comp::DEFLATE => {
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
// row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate
let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) };
let row_bytes = packed_row_bytes(cw as u64 * cpp as u64, bits, packing, float);
// the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too
let inflate_len =
crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?;
// Deflate compresses at most about 1:1032, so a source this far under half of its
// nominal length cannot reconstruct the chunk: refused before the nominal buffer is
// inflated and predicted, which padding-only work would make gigabytes.
if (src.len() as u64).saturating_mul(2048) < inflate_len {
return Err(RawError::Corrupt(format!("a deflated chunk of {inflate_len} bytes is packed into only {} bytes", src.len())));
}
let raw = inflate(src, inflate_len as usize)?;
// The stream has to reach the last row inside the image (edge strips may stop there);
// it is laid out in nominal rows, so shorter is a truncated chunk, not a picture.
let last = packed_row_bytes(vw as u64 * cpp as u64, bits, packing, float);
let visible = row_bytes.saturating_mul(vh.saturating_sub(1) as u64).saturating_add(last);
if (raw.len() as u64) < visible {
return Err(RawError::Corrupt(format!(
"a deflated chunk holds {} bytes, fewer than the {visible} bytes its in-image samples span",
raw.len()
)));
}
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
}
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
@@ -228,19 +292,31 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp:
jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
}
/// The most samples a tiled JPEG chunk or a white-balance preview may decode to: far more than
/// any real lossy tile or preview carries, and small enough that a JPEG header alone cannot size
/// a multi-gigabyte allocation.
pub(crate) const MAX_JPEG_SAMPLES: usize = 1 << 26;
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize, max_samples: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs);
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts);
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
// The header is read first: the JPEG's own dimensions, not the enclosing tile's, size every
// allocation `decode` makes, and they may claim billions of pixels.
d.decode_headers().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let c = if cpp == 1 { 1 } else { 3 };
if w == 0 || h == 0 || px.len() < w * h * c {
let out = w as u64 * h as u64 * c as u64;
if w == 0 || h == 0 || out > max_samples as u64 {
return Err(RawError::Corrupt(format!("a lossy JPEG of {w} × {h} × {c} would exceed the {max_samples} output samples allowed")));
}
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
if px.len() < w * h * c {
return Err(RawError::Corrupt("lossy JPEG tile: short output".into()));
}
Ok((px, w, h, c))
@@ -471,4 +547,97 @@ mod lossy_tests {
// half of the visible rows are missing
assert!(decode(8, 4).is_err());
}
/// A lossy JPEG whose header alone claims more samples than any real tile or preview (here
/// 40000 × 40000 × 3) is refused from the header, before the decoder allocates its output
/// from the JPEG's own dimensions.
#[test]
fn an_oversized_lossy_jpeg_header_is_refused_before_decoding() {
// A real 8 × 8 baseline JPEG, whose frame header then claims other dimensions: the decoder
// parses the header, and the size check is what refuses it.
let px = vec![128u8; 8 * 8 * 3];
let mut j = Vec::new();
jpeg_encoder::Encoder::new(&mut j, 90).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap();
// SOF0: marker, length, precision, then the height and the width
let sof = j.windows(2).position(|w| w == [0xff, 0xc0]).expect("a baseline frame header");
let claim = |j: &mut Vec<u8>, size: u16| {
j[sof + 5..sof + 7].copy_from_slice(&size.to_be_bytes());
j[sof + 7..sof + 9].copy_from_slice(&size.to_be_bytes());
};
claim(&mut j, 40_000);
let RawError::Corrupt(refused) = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err() else { panic!("a corrupt-data error") };
assert!(refused.contains("would exceed"), "{refused}");
// a header inside the budget passes the size check (and then fails to decode only for lack of data)
claim(&mut j, 1024);
match lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES) {
Ok(_) => {}
Err(RawError::Corrupt(m)) => assert!(!m.contains("would exceed"), "the smaller header was refused for its size: {m}"),
Err(other) => panic!("unexpected error: {other:?}"),
}
}
/// A lossy tile whose JPEG decodes far less than the nominal tile is fine: the rows the JPEG
/// covers hold its pixels, the rest of the output stays zero (and the tile's intermediate buffer
/// is sized by the JPEG, not by the nominal tile).
#[test]
fn a_small_lossy_jpeg_in_a_large_nominal_tile_decodes_with_zeros_below() {
let px: Vec<u8> = (0..8 * 8).flat_map(|_| [30u8, 90, 150]).collect();
let mut j = Vec::new();
jpeg_encoder::Encoder::new(&mut j, 100).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap();
let info = ImageInfo {
width: 16,
height: 16,
bits_per_sample: vec![8, 8, 8],
samples_per_pixel: 3,
compression: comp::LOSSY_JPEG,
photometric: 34892,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: 16, tile_height: 16 },
offsets: vec![0],
byte_counts: vec![j.len() as u64],
};
let RawData::U16(v) = read_image(&j, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") };
assert_eq!(v.len(), 16 * 16 * 3, "the output is the full nominal image");
let px = |x: usize, y: usize| &v[(y * 16 + x) * 3..(y * 16 + x) * 3 + 3];
assert!((px(3, 3)[0] as i32 - 30).abs() <= 3 && (px(3, 3)[2] as i32 - 150).abs() <= 3, "covered rows: {:?}", px(3, 3));
assert!(px(3, 12).iter().all(|&s| s == 0), "rows below the JPEG stay zero: {:?}", px(3, 12));
}
/// A deflated tile is refused when its packed bytes are too few for deflate to have produced
/// the nominal chunk, or when the stream stops short of the rows inside the image; a fully
/// covered flat tile still decodes.
#[test]
fn a_short_or_undercovering_deflated_tile_is_refused() {
let info = |byte_counts: u64| ImageInfo {
width: 512,
height: 512,
bits_per_sample: vec![16],
samples_per_pixel: 1,
compression: comp::ADOBE_DEFLATE,
photometric: 32803,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: 512, tile_height: 512 },
offsets: vec![0],
byte_counts: vec![byte_counts],
};
// a complete flat tile: 512 KiB of zeros compresses to a few hundred bytes (well within
// deflate's packing bound) and inflates to every nominal byte the tile claims
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 512 * 512 * 2], 6);
assert_eq!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Ok(RawData::U16(vec![0; 512 * 512])));
// the same 1 KiB packed, but the stream claims a whole tile: a few hundred bytes cannot
// reconstruct 512 KiB from a decoder that compresses no further than about 1:1032
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1024], 6);
assert!(matches!(read_image(&zlib, &info(512 * 512 * 2), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))), "{zlib:?}");
// 500 KiB of zeros compress to a stream within the packing bound and inflating to more
// bytes than the in-image samples span, but 24 KiB short of the whole nominal tile:
// refused as truncated rather than filled with the predictor's padding
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 500 * 1000], 6);
assert!(matches!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))));
}
}
+58 -1
View File
@@ -89,6 +89,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// the white balance is measured from the samples, so the header probe reads them too
let chunks = info.chunks(bytes.len() as u64);
let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?;
// 12-bit MSB packing takes 3 bytes per 2 samples; a strip that cannot hold the declared
// samples is a truncated file, not a picture — unpacking it would run past the real bytes
// through every output sample (a short strip asks for the whole allocation and more).
let need = n.div_ceil(2).saturating_mul(3);
if src.len() < need {
return Err(RawError::Corrupt(format!("a Mef strip of {} bytes cannot hold the declared {n} samples", src.len())));
}
let mut d = vec![0u16; n];
crate::unpack::unpack_msb(src, 12, &mut d);
let white = super::white_from_data(&d, 12);
@@ -138,6 +145,10 @@ mod tests {
}
fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> {
sample_strip(w, h, &pack(px))
}
fn sample_strip(w: u32, h: u32, strip: &[u8]) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
@@ -148,7 +159,7 @@ mod tests {
raw.set(t::ORIENTATION, Value::Short(vec![6]));
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] });
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip.to_vec()] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into()));
ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into()));
@@ -156,6 +167,52 @@ mod tests {
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
/// A strip that cannot hold the declared samples is a truncated file, refused as corrupt
/// instead of unpacked: `unpack_msb` would otherwise run `n` samples through the few real
/// bytes, filling the rest from nothing.
#[test]
fn a_strip_too_short_for_the_declared_samples_is_refused() {
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
let f = sample(16, 6, &px);
assert!(crate::decode(&f).is_ok());
// A truncated file shaped like a real one: IFD tables first, strip last — only its first
// 20 packed bytes remain, while the IFD still declares the full 1.5 B/sample length. The
// probe accepts the file on the declared length; before the byte gate below, `unpack_msb`
// ran the dimension-sized allocation through those 20 bytes, filling it from nothing.
let u32be = |b: &[u8], a: usize| u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]);
let full = pack(&px);
let at = f.windows(full.len()).position(|w| w == full).expect("strip bytes in the file");
let after_strip = at + full.len();
let moved = after_strip - (at + 20);
let mut g = f[..8].to_vec();
g.extend(std::iter::repeat_n(0u8, at + 20 - 8));
g.extend_from_slice(&f[after_strip..]);
// repoint the first-IFD pointer and ifd0's MAKE / MODEL / SubIfds offsets (the tables now
// sit `moved` earlier); the raw sub-IFD's strip offset and byte count are patched below
let poke = |b: &mut [u8], a: usize| {
let v = u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]);
if v as usize >= after_strip {
b[a..a + 4].copy_from_slice(&(v - moved as u32).to_be_bytes());
}
};
let ifd0_at = u32be(&g, 4) as usize - moved;
g[4..8].copy_from_slice(&(ifd0_at as u32).to_be_bytes());
let n = u16::from_be_bytes([g[ifd0_at], g[ifd0_at + 1]]) as usize;
for i in 0..n {
poke(&mut g, ifd0_at + 2 + i * 12 + 8);
}
// the raw sub-IFD: entries follow its count; tag 273 (StripOffsets) is the seventh, held
// inline — repoint it at the strip's new home, and append the truncated strip for real
let sub_at = u32be(&g, ifd0_at + 2 + 2 * 12 + 8) as usize;
let e_offsets = sub_at + 2 + 6 * 12 + 8;
let strip_at = g.len() as u32;
g[e_offsets..e_offsets + 4].copy_from_slice(&strip_at.to_be_bytes());
g.extend_from_slice(&full[..20]);
assert_eq!(crate::probe(&g), Some(RawFormat::Mef));
let r = crate::decode(&g);
assert!(matches!(r, Err(RawError::Corrupt(_))), "{r:?}");
}
#[test]
fn decodes_packed_12_bit() {
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
+1 -1
View File
@@ -66,7 +66,7 @@ fn median(v: &mut [f64]) -> Option<f64> {
fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> {
let jpeg = crate::preview::embedded_preview(bytes)?;
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?;
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3, crate::tiffraw::MAX_JPEG_SAMPLES).ok()?;
if ch != 3 {
return None;
}
+36 -4
View File
@@ -118,14 +118,34 @@ impl ImageInfo {
}
}
/// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data"
/// using `data_len`.
pub fn chunks(&self, data_len: u64) -> Vec<Chunk> {
/// The chunk count the image declares (offsets present, cut to the layout's plane capacity) with the
/// geometry `chunks` needs to place each one. No descriptors are built, so a pathological strip array
/// costs it a length check.
fn chunk_grid(&self) -> (usize, u32, usize) {
let (across, down) = self.grid();
let per_plane = across as usize * down as usize;
let planes = if self.planar == 2 { self.samples_per_pixel as usize } else { 1 };
(self.offsets.len().min(per_plane.saturating_mul(planes)), across, per_plane)
}
fn declared_chunk_count(&self) -> usize {
self.chunk_grid().0
}
/// The one chunk when the image has exactly one, else `None` — and no descriptor vector beyond it. Preview
/// extraction only serves single-chunk images, so it asks here before serving a hostile multi-strip layout.
pub fn single_chunk(&self, data_len: u64) -> Option<Chunk> {
if self.declared_chunk_count() != 1 {
return None;
}
self.chunks(data_len).into_iter().next()
}
/// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data"
/// using `data_len`.
pub fn chunks(&self, data_len: u64) -> Vec<Chunk> {
let (n, across, per_plane) = self.chunk_grid();
let (cw, ch) = self.chunk_size();
let n = self.offsets.len().min(per_plane.saturating_mul(planes));
let mut out = Vec::with_capacity(n);
// the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk)
let mut sorted: Vec<u64> = Vec::new();
@@ -192,6 +212,18 @@ mod tests {
}
}
/// One offset is one chunk, and `single_chunk` refuses to build descriptors when the image declares more.
#[test]
fn single_chunk_is_the_only_chunk() {
let one = strips(vec![50], vec![10]);
assert_eq!(one.single_chunk(100).map(|c| (c.index, c.offset, c.len)), one.chunks(100).first().map(|c| (c.index, c.offset, c.len)));
// the inferred length matches too, and never falls back past the data
let inferred = strips(vec![70], vec![]);
assert_eq!(inferred.single_chunk(100).map(|c| c.len), Some(30));
assert!(strips(vec![50, 10], vec![10, 5]).single_chunk(100).is_none(), "a multi-chunk image has no single chunk");
assert!(strips(Vec::new(), Vec::new()).single_chunk(100).is_none());
}
#[test]
fn missing_byte_counts_run_to_the_next_larger_offset() {
let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]);
+34 -1
View File
@@ -55,7 +55,11 @@ pub fn parse_makernote(data: &[u8], offset: u64, len: u64, order: ByteOrder, mak
Some(b"MM") => Some(ByteOrder::Big),
_ => None,
};
let opts = ParseOptions { max_ifds: 64, max_depth: 3, ..Default::default() };
// A fixed value budget: a maker note is at most tens of kilobytes of values in any honest file, and
// computing the budget from the buffer's length multiplies the file length (a hostile entry in a
// sparse header-read buffer, where the buffer holds the file's logical size but only a few
// kilobytes are loaded, would decode allocations no actual data backs).
let opts = ParseOptions { max_ifds: 64, max_depth: 3, value_budget: Some(1 << 20), ..Default::default() };
let try_parse = |kind: MakerNoteKind, ifd_at: u64, base: u64, ord: ByteOrder| -> Option<MakerNote> {
let (ifd, _) = parse_ifd_at(data, ifd_at, ord, base, false, &opts).ok()?;
(!ifd.entries.is_empty()).then_some(MakerNote { kind, order: ord, base, ifd })
@@ -192,6 +196,35 @@ mod tests {
assert_eq!(m.ifd.u64s(2).unwrap(), vec![9, 0]);
}
/// A maker note's value budget is fixed, not computed from the length of the buffer it sits in:
/// an entry claiming more than the note budget (here 2 MB) is dropped, a fitting one is read. In a
/// sparse header read the buffer holds the file's logical size while only kilobytes were loaded,
/// so a length-scaled budget would let an unrelated entry decode megabytes of zeros (see the focus
/// reader, try_read_exif_with).
#[test]
fn a_note_entry_past_the_fixed_value_budget_is_dropped() {
let order = ByteOrder::Little;
let mut note = b"SONY DSC \0\0\0".to_vec();
// the Sony IFD directly after the 12-byte header, one fitting BYTE entry and a decoy
// BYTE[2 MB] entry pointing into the file's own (zero-filled) body
order.put_u16(&mut note, 2);
order.put_u16(&mut note, 0x201b);
order.put_u16(&mut note, 1);
order.put_u32(&mut note, 1);
note.extend([3, 0, 0, 0]);
order.put_u16(&mut note, 0x9900);
order.put_u16(&mut note, 1);
order.put_u32(&mut note, 2 << 20);
order.put_u32(&mut note, 200);
order.put_u32(&mut note, 0);
let mut bytes = note.clone();
bytes.resize(200 + (2 << 20), 0); // the decoy's value range is inside the real bytes
let m = parse_makernote(&bytes, 0, note.len() as u64, order, "SONY").unwrap();
assert_eq!(m.kind, MakerNoteKind::Sony);
assert!(m.ifd.get(0x201b).is_some(), "the fitting entry is read");
assert!(m.ifd.get(0x9900).is_none(), "the decoy is dropped by the value budget");
}
#[test]
fn garbage_note_is_none() {
let (bytes, off, len) = with_note(ByteOrder::Little, vec![0xff; 40]);
+19
View File
@@ -338,6 +338,25 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use crate::Value;
use crate::tags as t;
use crate::writer::{IfdBuilder, TiffWriter};
/// A value budget the caller sets bounds what is decoded: an entry claiming more value bytes than
/// the budget allows is dropped, entries that fit are read (the sparse header-read path, where the
/// buffer holds the file's logical size but only a few kilobytes were ever loaded, sizes its
/// budget from what was fetched, not from whatever length the file claims).
#[test]
fn the_value_budget_drops_over_budget_entries_and_keeps_the_rest() {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("Keep".into()));
ifd0.set(0x9000, Value::Byte(vec![0; 2 << 20]));
let file = TiffWriter { order: ByteOrder::Little, bigtiff: false }.write(&[ifd0]).unwrap();
let opts = ParseOptions { value_budget: Some(1 << 20), ..Default::default() };
let tiff = Tiff::parse_with(&file, &opts).unwrap();
assert_eq!(tiff.ifds[0].string(t::MAKE), Some("Keep".to_string()), "a fitting entry is read");
assert!(tiff.ifds[0].get(0x9000).is_none(), "the over-budget entry is dropped");
}
/// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer
/// `next`; a valid one-entry IFD sits at 16.
+4
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
craft-ui = { workspace = true }
craft-window = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-color = { workspace = true }
@@ -44,3 +45,6 @@ lightcraft-merge = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
+427 -265
View File
@@ -1,7 +1,10 @@
//! Application panel identities and persistence around the shared docking renderer.
//! Document contents remain owned by their existing view implementations.
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
use craft_ui::docking::{
Action, Column, ColumnLocation, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Group, GroupLocation, Layout, Location,
Node, PanelLimits, PanelState, Permissions, Placement, Side, UiDock, Zone,
};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -33,21 +36,41 @@ impl Panel {
Self::Presets => "Presets",
}
}
fn default_side(self) -> Zone {
match self {
Self::Canvas => Zone::Right,
Self::Library => Zone::Left,
Self::Tools => Zone::Right,
Self::Presets => Zone::Right,
}
/// The dock the panel opens in when it has no recorded place.
fn home_side(self) -> Side {
if self == Self::Library { Side::Start } else { Side::End }
}
fn parse(id: &str) -> Result<Self, String> {
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
/// The width of the panel's own column in the default workspace.
fn home_width(self) -> f32 {
match self {
Self::Canvas => crate::state::MIN_PHOTO_WIDTH,
Self::Library => crate::state::LEFT_WIDTH.default,
Self::Tools => crate::state::RIGHT_WIDTH.default,
Self::Presets => PRESETS_WIDTH,
}
}
}
const PRESETS_WIDTH: f32 = 300.0;
/// The arrangement generation saved as `UiState::docking_version`. Settings without it hold the
/// side panels in split frames; they start again from [`default_workspace`].
pub const DOCKING_VERSION: u32 = 1;
/// Drops saved settings' arrangement from before [`DOCKING_VERSION`], with the places of its
/// closed panels, so the app starts from the default workspace; every other setting stays.
/// Returns true when an arrangement was dropped.
pub fn drop_outdated(settings: &mut serde_json::Map<String, Value>) -> bool {
if settings.get("dockingVersion").and_then(Value::as_u64).is_some_and(|version| version >= u64::from(DOCKING_VERSION)) {
return false;
}
settings.insert("dockingVersion".into(), json!(DOCKING_VERSION));
settings.remove("docking").is_some_and(|docking| !docking.is_null())
}
fn permissions(panel: &Panel) -> Permissions {
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
// Panels open and split beside the photo view, but never join it as tabs.
if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
}
fn tabs(panel: Panel) -> Node<Panel> {
@@ -58,246 +81,162 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
/// The saved arrangement. Closed panels keep their last group, side, size or floating rect.
pub type Workspace = craft_ui::docking::Workspace<Panel>;
fn column(panel: Panel, width: f32) -> Column<Panel> {
Column::new(vec![Group::new(vec![panel], 0)], width)
}
impl Default for Workspace {
fn default() -> Self {
Self {
layout: Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default),
tabs(Panel::Library),
split(
SplitAxis::Horizontal,
SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
tabs(Panel::Tools),
),
)),
floating: Vec::new(),
},
hidden: Vec::new(),
}
/// Library in a start dock and Tools in an end dock beside the photo. Presets has a column of
/// its own on Tools' photo side rather than a group under Tools: both are tall lists that are
/// read side by side while trying presets, and stacking them would halve both.
fn docked(left: f32, right: f32) -> Workspace {
let start = Node::Dock { side: Side::Start, columns: vec![column(Panel::Library, left)] };
let end = Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, right), column(Panel::Presets, PRESETS_WIDTH)] };
Workspace::new(Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(left),
start,
split(SplitAxis::Horizontal, SplitSize::FixedSecond(right + PRESETS_WIDTH), tabs(Panel::Canvas), end),
)),
floating: Vec::new(),
})
}
pub fn default_workspace() -> Workspace {
docked(crate::state::LEFT_WIDTH.default, crate::state::RIGHT_WIDTH.default)
}
/// Where a panel without a usable recorded place opens: its own column at its dock's window
/// edge, or Presets on the photo side of Tools while Tools is in the end dock.
fn home(workspace: &Workspace, panel: Panel) -> Location<Panel> {
let column = ColumnLocation { side: panel.home_side(), width: panel.home_width(), iconic: false, iconic_width: None };
let docked_at =
|other: Panel| workspace.layout.location(&other).ok().and_then(|location| location.group).and_then(|group| group.column).map(|c| c.side);
let beside = (panel == Panel::Presets && docked_at(Panel::Tools) == Some(Side::End)).then_some(Panel::Tools);
Location {
floating: None,
anchor: beside,
placement: if beside.is_some() { Placement::Column { after: true } } else { Placement::Tab { before: None } },
size: None,
stack: None,
sibling: Vec::new(),
group: Some(GroupLocation { minimized: false, height: None, column: Some(column) }),
}
}
impl Workspace {
pub fn validate(&self) -> Result<(), String> {
self.layout.validate().map_err(|error| error.to_string())?;
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
return Err("The panel layout must contain its document view and bounded hidden panels".into());
}
// Saved settings cannot turn the protected document view into a floating or closable tab.
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
return Err("The document view cannot float".into());
}
let mut nodes: Vec<_> = self.layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
return Err("The document view cannot share a tab group".into());
}
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
return Err("The document view cannot be collapsed".into());
}
_ => {}
}
}
let mut seen = std::collections::HashSet::new();
for (panel, location) in &self.hidden {
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
return Err("A hidden panel has an invalid or duplicate identity".into());
}
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
return Err("A hidden panel cannot restore over the document view".into());
}
let mut probe = self.layout.clone();
if let Err(error) = probe.restore(*panel, location)
&& error != craft_ui::docking::DockError::MissingTarget
{
return Err(error.to_string());
}
}
Ok(())
}
fn error(error: DockError) -> String {
error.to_string()
}
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
self.validate()?;
let mut next = self.clone();
if let Action::Close { panel } = &action {
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
next.hidden.retain(|(id, _)| id != panel);
next.hidden.push((*panel, location));
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
next.hidden.retain(|(id, _)| id != panel);
/// The shared checks plus LightCraft's own: saved settings cannot float, hide, share or
/// collapse the photo view, nor send a hidden panel back onto it.
pub fn validate(workspace: &Workspace) -> Result<(), String> {
workspace.validate().map_err(error)?;
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
let mut alone = false;
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
Node::Tabs { panels, .. } => alone |= panels.as_slice() == [Panel::Canvas],
_ => {}
}
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
next.validate()?;
*self = next;
Ok(())
}
if !alone {
return Err("The document view must stay docked in a group of its own".into());
}
if workspace.hidden.iter().any(|(_, location)| {
location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center))
}) {
return Err("A hidden panel cannot restore over the document view".into());
}
Ok(())
}
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
if self.layout.contains(&panel) == visible {
fn apply(workspace: &mut Workspace, action: Action<Panel>) -> Result<(), String> {
let mut next = workspace.clone();
next.apply(action, permissions).map_err(error)?;
validate(&next)?;
*workspace = next;
Ok(())
}
/// Shows a panel at its recorded place, else in its dock column ([`home`]). A panel that
/// already has the asked visibility keeps its tab selection.
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
if workspace.layout.contains(&panel) == visible {
return Ok(());
}
if !visible {
return apply(workspace, Action::Close { panel });
}
validate(workspace)?;
// A recorded place that can't be rebuilt falls back to joining the fallback's tabs. The
// photo view takes no tabs, so that is refused and the panel goes to its home column below.
if workspace.hidden.iter().any(|(hidden, _)| *hidden == panel) {
let mut next = workspace.clone();
if next.set_visible(panel, true, Some(Panel::Canvas), permissions).is_ok() && validate(&next).is_ok() {
*workspace = next;
return Ok(());
}
if !visible {
return self.apply(Action::Close { panel });
}
self.validate()?;
let mut next = self.clone();
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
next.hidden.retain(|(id, _)| *id != panel);
if let Some(saved) = &mut location
&& saved.floating.is_some()
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
{
// The other members of a floating group may have been closed too.
saved.anchor = None;
saved.placement = Placement::Tab { before: None };
}
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
if !restored {
next.layout
.apply_with_permissions(
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
permissions,
)
.map_err(|error| error.to_string())?;
}
next.validate()?;
*self = next;
Ok(())
}
let mut next = workspace.clone();
next.hidden.retain(|(hidden, _)| *hidden != panel);
let location = home(&next, panel);
next.hidden.push((panel, location));
next.set_visible(panel, true, Some(Panel::Canvas), permissions).map_err(error)?;
validate(&next)?;
*workspace = next;
Ok(())
}
/// Membership is passive state; an explicit command also exposes the panel's body.
pub fn reveal(&mut self, panel: Panel) -> Result<(), String> {
self.set_visible(panel, true)?;
self.apply(Action::Activate { panel })
/// Membership is passive state; an explicit command also exposes the panel's body.
fn reveal(workspace: &mut Workspace, panel: Panel) -> Result<(), String> {
set_visible(workspace, panel, true)?;
apply(workspace, Action::Activate { panel })?;
if workspace.layout.panel_state(&panel) != PanelState::Front {
// A minimized window or group opens again; where nothing is minimized the action is
// refused and the layout stays as it is.
let _ = apply(workspace, Action::SetMinimized { panel, minimized: false });
}
Ok(())
}
pub fn exposed(&self, panel: Panel) -> bool {
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
return true;
}
let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default();
while let Some(node) = nodes.pop() {
match node {
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
_ => {}
}
}
false
}
pub fn exposed_in(workspace: &Workspace, panel: Panel) -> bool {
workspace.layout.panel_state(&panel) == PanelState::Front
}
fn command(&mut self, params: &Value) -> Result<(), String> {
self.validate()?;
if let Some(action) = params.get("action") {
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
return self.apply(action);
}
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
if operation == "resizeSplit" {
let path: Vec<bool> =
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
let size: SplitSize =
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
return self.apply(Action::ResizeSplit { path, size });
}
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
let action = match operation {
"open" => return self.reveal(panel),
"close" => Action::Close { panel },
"activate" => Action::Activate { panel },
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
"resizeStack" => {
let value = params.get("height").ok_or("height is required")?;
let height = if value.is_null() {
None
} else {
Some(
value
.as_f64()
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
.ok_or("height must be finite and bounded")? as f32,
)
};
Action::ResizeStack { panel, height }
}
"float" | "moveFloating" => {
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
let mut values = [0.0f32; 4];
for (output, value) in values.iter_mut().zip(rect) {
*output = value
.as_f64()
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
.ok_or("rect values must be finite and bounded")? as f32;
}
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
}
"move" => {
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be center, left, right, top or bottom".into()),
};
let placement = if let Some(before) = params.get("before") {
if !matches!(placement, Placement::Split(Zone::Center)) {
return Err("before only applies to a center move".into());
}
Placement::Tab {
before: if before.is_null() {
None
} else {
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
},
}
} else {
placement
};
Action::Move { panel, anchor, placement }
}
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
};
self.apply(action)
/// `ui.dock` params, parsed by the shared command vocabulary.
fn run(workspace: &mut Workspace, params: &Value) -> Result<(), String> {
validate(workspace)?;
let mut tagged = params.clone();
let fields = tagged.as_object_mut().ok_or("ui.dock params must be an object")?;
fields.insert("command".into(), json!("ui.dock"));
let command: DockCommand<Panel> = serde_json::from_value(tagged).map_err(|error| error.to_string())?;
if let DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel })) = command {
return reveal(workspace, panel);
}
let mut next = workspace.clone();
// `ui.dock` operations carry their own rects; only `window.panel.float` reads the space.
command.apply(&mut next, false, &FloatSpace::new(egui::Rect::ZERO), permissions).map_err(error)?;
validate(&next)?;
*workspace = next;
Ok(())
}
fn from_ui(app: &crate::LightcraftApp) -> Workspace {
let mut workspace = Workspace::default();
if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() {
*size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width));
if let Node::Split { size, .. } = second.as_mut() {
*size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width));
}
}
workspace
docked(crate::state::LEFT_WIDTH.clamp(app.ui.left_width), crate::state::RIGHT_WIDTH.clamp(app.ui.right_width))
}
fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> {
workspace.set_visible(Panel::Library, app.ui.left_panel)?;
workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
workspace.set_visible(Panel::Presets, app.ui.presets)
set_visible(workspace, Panel::Library, app.ui.left_panel)?;
set_visible(workspace, Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
set_visible(workspace, Panel::Presets, app.ui.presets)
}
fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
@@ -314,10 +253,12 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
let Ok(location) = workspace.layout.location(&panel) else {
continue;
};
let value = match (location.floating, location.placement, location.size) {
(Some([_, _, width, _]), _, _)
| (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width)))
| (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width),
let column = location.group.and_then(|group| group.column);
let value = match (location.floating, column, location.placement, location.size) {
(Some([_, _, width, _]), ..) => Some(width),
(None, Some(column), ..) => Some(column.width),
(None, None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width)))
| (None, None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width),
// A vertical split stores height, and the opposite fixed side sizes a sibling.
_ => None,
};
@@ -328,7 +269,7 @@ fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
}
pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool {
app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel))
app.ui.docking.as_ref().is_none_or(|workspace| exposed_in(workspace, panel))
}
pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> {
@@ -338,6 +279,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result
// the sidebar it scrolls, also when it is a tab behind another panel
Some(Panel::Library)
} else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets"
|| id == "view.rightPanel"
|| id.starts_with("section.")
|| id.starts_with("tool.") && id != "tool.none")
&& app.ui.right != crate::state::RightPanel::None
@@ -349,7 +291,7 @@ pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result
if let Some(panel) = panel {
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
sync(&mut workspace, app)?;
workspace.reveal(panel)?;
reveal(&mut workspace, panel)?;
cancel_replaced_gesture(app, Some(&workspace));
app.ui.docking = Some(workspace);
}
@@ -381,13 +323,13 @@ pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> R
fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result<Value, String> {
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
if reset {
workspace = Workspace::default();
workspace = default_workspace();
// The usual LightCraft starting workspace shows its current tool beside the photo.
workspace.set_visible(Panel::Library, false)?;
workspace.set_visible(Panel::Presets, false)?;
set_visible(&mut workspace, Panel::Library, false)?;
set_visible(&mut workspace, Panel::Presets, false)?;
} else {
sync(&mut workspace, app)?;
workspace.command(params)?;
run(&mut workspace, params)?;
}
if !renderer_action {
cancel_replaced_gesture(app, Some(&workspace));
@@ -404,7 +346,7 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren
pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
if let Err(error) = validate(&workspace).and_then(|()| sync(&mut workspace, app)) {
app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), app.tick_now() + 8.0, None));
workspace = from_ui(app);
let _ = sync(&mut workspace, app);
@@ -415,12 +357,32 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
app.ui.docking = Some(workspace.clone());
let mut style = DockStyle::from_ui(ui);
style.min_pane = 96.0;
// Free docking: a panel dropped at an empty window edge makes a dock, and the photo takes
// drops on all four sides.
style.new_dock_zones = true;
style.side_edge_zones = false;
style.float_label = crate::i18n::tr("Float panel").into();
style.close_label = crate::i18n::tr("Close panel").into();
style.move_label = crate::i18n::tr("Move to group").into();
style.move_panel_to_label = crate::i18n::tr("Move to group").into();
style.panels_label = crate::i18n::tr("Panels").into();
style.resize_label = crate::i18n::tr("Resize panels").into();
style.resize_window_label = crate::i18n::tr("Resize panel window").into();
style.move_group_label = crate::i18n::tr("Move tab group").into();
style.move_group_to_label = crate::i18n::tr("Move group to").into();
style.float_group_label = crate::i18n::tr("Float tab group").into();
style.close_group_label = crate::i18n::tr("Close tab group").into();
style.close_others_label = crate::i18n::tr("Close other panels in group").into();
style.minimize_label = crate::i18n::tr("Minimize").into();
style.expand_label = crate::i18n::tr("Expand").into();
style.move_column_label = crate::i18n::tr("Move panel column").into();
style.collapse_to_icons_label = crate::i18n::tr("Collapse to Icons").into();
style.expand_panels_label = crate::i18n::tr("Expand Panels").into();
style.collapse_column_label = crate::i18n::tr("Collapse column").into();
style.expand_column_label = crate::i18n::tr("Expand column").into();
style.auto_collapse_label = crate::i18n::tr("Auto-Collapse Iconic Panels").into();
style.move_window_label = crate::i18n::tr("Move panel window").into();
style.close_window_label = crate::i18n::tr("Close panel window").into();
style.float_hint_label = crate::i18n::tr("Hold {key} to undock").into();
style.background = t.chrome;
style.tab_background = t.chrome;
style.active_background = t.canvas;
@@ -431,7 +393,7 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
style.font = t.font(12.0);
// Tool and preset panels publish hover previews before the photo consumes them.
let mut canvas = None;
let output = DockArea::new(egui::Id::unique("lightcraft-docking")).show_with_limits(
let output = DockArea::new(egui::Id::unique("lightcraft-docking")).locked(workspace.locked).show_with_limits(
ui,
&workspace.layout,
&style,
@@ -497,14 +459,15 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
}
}
/// Keep the established ui.set width controls connected to the panel's current parent split.
/// Keep the established ui.set width controls connected to the panel's dock column, floating
/// window or parent split.
pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> {
state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width);
state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width);
let Some(workspace) = state.docking.as_mut() else {
return Ok(());
};
workspace.validate()?;
validate(workspace)?;
for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] {
if params.get(key).is_none() {
continue;
@@ -513,6 +476,10 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
if let Some(rect) = &mut location.floating {
rect[2] = width;
}
if let Some(column) = location.group.as_mut().and_then(|group| group.column.as_mut()) {
column.width = width;
continue;
}
match location.placement {
Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)),
Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)),
@@ -520,10 +487,14 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
}
continue;
}
if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) {
if let Some(group) = workspace.layout.floating.iter().find(|group| group.contains(&panel)) {
let mut rect = group.rect;
rect[2] = width;
workspace.apply(Action::MoveFloating { panel, rect })?;
apply(workspace, Action::MoveFloating { panel, rect })?;
continue;
}
if workspace.layout.location(&panel).is_ok_and(|location| location.group.is_some_and(|group| group.column.is_some())) {
apply(workspace, Action::ResizeColumn { panel, width })?;
continue;
}
let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default();
@@ -533,7 +504,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
let contains = |node: &Node<Panel>| match node {
Node::Tabs { panels, .. } => panels.contains(&panel),
Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
Node::Split { .. } => false,
_ => false,
};
if *axis == SplitAxis::Horizontal {
if contains(first) {
@@ -552,7 +523,7 @@ pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(
}
}
if let Some(action) = change {
workspace.apply(action)?;
apply(workspace, action)?;
}
}
Ok(())
@@ -568,6 +539,23 @@ fn panel_limits(panel: &Panel) -> PanelLimits {
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
}
/// The columns of the dock on `side`, from the window edge inwards: width and the panels of
/// each group.
#[cfg(test)]
pub(crate) fn columns(workspace: &Workspace, side: Side) -> Vec<(f32, Vec<Vec<Panel>>)> {
let mut nodes: Vec<_> = workspace.layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
Node::Dock { side: dock, columns } if *dock == side => {
return columns.iter().map(|column| (column.width, column.groups.iter().map(|group| group.panels.clone()).collect())).collect();
}
_ => {}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -587,7 +575,7 @@ mod tests {
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
);
let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) };
Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() }
Workspace::new(Layout { root: Some(root), floating: Vec::new() })
}
#[test]
@@ -598,23 +586,20 @@ mod tests {
.into_iter()
.map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) })
.collect();
app.ui.docking = Some(Workspace {
layout: Layout {
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
floating: Vec::new(),
},
hidden: Vec::new(),
});
app.ui.docking = Some(Workspace::new(Layout {
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
floating: Vec::new(),
}));
let undo = app.session.undo.len();
app.run("panel.crop", json!({})).unwrap();
assert_eq!(app.ui.right, crate::state::RightPanel::Crop);
let workspace = app.ui.docking.as_ref().unwrap();
assert!(workspace.exposed(Panel::Tools));
assert!(exposed_in(workspace, Panel::Tools));
assert_eq!(app.session.undo.len(), undo);
let saved = serde_json::to_string(workspace).unwrap();
let reloaded: Workspace = serde_json::from_str(&saved).unwrap();
reloaded.validate().unwrap();
assert!(reloaded.exposed(Panel::Tools));
validate(&reloaded).unwrap();
assert!(exposed_in(&reloaded, Panel::Tools));
}
#[test]
@@ -626,7 +611,7 @@ mod tests {
(SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false),
] {
let workspace = tools_split(axis, size, first);
workspace.validate().unwrap();
validate(&workspace).unwrap();
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}");
@@ -634,15 +619,15 @@ mod tests {
}
#[test]
fn legacy_width_publication_reads_own_horizontal_and_floating_widths() {
fn legacy_width_publication_reads_own_split_and_floating_widths() {
for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] {
let workspace = tools_split(SplitAxis::Horizontal, size, first);
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0));
}
let mut workspace = Workspace::default();
workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
let mut workspace = default_workspace();
apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!(app.ui.right_width, 390.0);
@@ -650,12 +635,189 @@ mod tests {
#[test]
fn legacy_width_update_preserves_hidden_top_panel_height() {
let mut workspace = Workspace::default();
workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
workspace.set_visible(Panel::Tools, false).unwrap();
let mut workspace = default_workspace();
apply(&mut workspace, Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
set_visible(&mut workspace, Panel::Tools, false).unwrap();
let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size;
let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() };
set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap();
assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height);
}
/// Settings written before workspaces had a version: `{layout, hidden}`, a floating
/// window as one group, hidden locations without dock state.
#[test]
fn version_one_workspace_file_loads_and_reopens_its_hidden_panel() {
let v1 = json!({
"layout": {
"root": {"Split": {
"axis": "Horizontal",
"size": {"FixedFirst": 300.0},
"first": {"Tabs": {"panels": ["library"], "active": 0}},
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
}},
"floating": [{"panels": ["tools"], "active": 0, "rect": [120.0, 90.0, 320.0, 600.0]}]
},
"hidden": [["presets", {
"floating": null,
"anchor": "canvas",
"placement": {"Split": "Right"},
"size": {"FixedSecond": 280.0},
"stack": null,
"sibling": ["canvas"]
}]]
});
let mut workspace: Workspace = serde_json::from_value(v1).unwrap();
validate(&workspace).unwrap();
assert_eq!(workspace.version, 1);
let window = workspace.layout.floating.first().unwrap();
assert!(window.contains(&Panel::Tools));
assert_eq!(window.rect, [120.0, 90.0, 320.0, 600.0]);
assert!(exposed_in(&workspace, Panel::Library));
set_visible(&mut workspace, Panel::Presets, true).unwrap();
let location = workspace.layout.location(&Panel::Presets).unwrap();
assert_eq!((location.anchor, location.size), (Some(Panel::Canvas), Some(SplitSize::FixedSecond(280.0))));
let saved = serde_json::to_value(&workspace).unwrap();
assert_eq!(saved["version"], 2);
assert_eq!(saved["layout"]["floating"][0]["panels"], json!(["tools"]), "a one-group window keeps the v1 shape");
}
#[test]
fn default_workspace_docks_library_at_the_start_and_tools_and_presets_at_the_end() {
let workspace = default_workspace();
validate(&workspace).unwrap();
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
assert_eq!(
columns(&workspace, Side::End),
[(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])],
"Presets has its own column on Tools' photo side"
);
let Some(Node::Split { second, .. }) = &workspace.layout.root else { panic!("{:?}", workspace.layout.root) };
let Node::Split { first, .. } = &**second else { panic!("{second:?}") };
assert_eq!(**first, tabs(Panel::Canvas), "the photo sits between the docks");
assert!(workspace.layout.floating.is_empty());
let app = app_with_widths();
let workspace = from_ui(&app);
assert_eq!(columns(&workspace, Side::Start).first().map(|column| column.0), Some(333.0));
assert_eq!(columns(&workspace, Side::End).first().map(|column| column.0), Some(410.0));
}
#[test]
fn dock_column_widths_publish_and_follow_width_controls() {
let mut app = app_with_widths();
let workspace = docked(300.0, 310.0);
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (300.0, 310.0));
let mut state = crate::UiState { docking: Some(workspace), left_width: 350.0, right_width: 400.0, ..Default::default() };
set_visible(state.docking.as_mut().unwrap(), Panel::Library, false).unwrap();
set_widths(&mut state, &json!({"leftWidth":350.0, "rightWidth":400.0})).unwrap();
let workspace = state.docking.as_mut().unwrap();
assert_eq!(columns(workspace, Side::End).first().map(|column| column.0), Some(400.0), "Tools' column");
set_visible(workspace, Panel::Library, true).unwrap();
assert_eq!(columns(workspace, Side::Start), [(350.0, vec![vec![Panel::Library]])], "the closed Library's column");
}
#[test]
fn a_panel_without_a_recorded_place_opens_in_its_dock() {
let root = split(
SplitAxis::Horizontal,
SplitSize::FixedSecond(300.0),
tabs(Panel::Canvas),
Node::Dock { side: Side::End, columns: vec![column(Panel::Tools, 300.0)] },
);
let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() });
set_visible(&mut workspace, Panel::Presets, true).unwrap();
set_visible(&mut workspace, Panel::Library, true).unwrap();
validate(&workspace).unwrap();
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
assert_eq!(columns(&workspace, Side::End), [(300.0, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]);
// Without Tools in the end dock, Presets takes the dock's window edge.
let mut workspace = default_workspace();
set_visible(&mut workspace, Panel::Presets, false).unwrap();
workspace.hidden.clear();
apply(&mut workspace, Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
set_visible(&mut workspace, Panel::Presets, true).unwrap();
assert_eq!(columns(&workspace, Side::End), [(PRESETS_WIDTH, vec![vec![Panel::Presets]])]);
}
fn saved_settings(docking: Value, version: Option<u32>) -> serde_json::Map<String, Value> {
let ui = crate::UiState { thumb_size: 210.0, filmstrip: false, presets: true, ..Default::default() };
let mut saved = serde_json::to_value(&ui).unwrap();
let fields = saved.as_object_mut().unwrap();
fields.insert("docking".into(), docking);
match version {
Some(version) => fields.insert("dockingVersion".into(), json!(version)),
None => fields.remove("dockingVersion"),
};
fields.clone()
}
#[test]
fn a_saved_split_frame_layout_without_a_docking_version_loads_as_the_default_docks() {
let frames = split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(333.0),
tabs(Panel::Library),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(410.0), tabs(Panel::Canvas), tabs(Panel::Tools)),
);
let frames = Workspace::new(Layout { root: Some(frames), floating: Vec::new() });
validate(&frames).unwrap();
for version in [None, Some(0)] {
let mut fields = saved_settings(serde_json::to_value(&frames).unwrap(), version);
assert!(drop_outdated(&mut fields));
let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap();
assert!(ui.docking.is_none());
assert_eq!(ui.docking_version, DOCKING_VERSION);
assert_eq!((ui.thumb_size, ui.filmstrip, ui.presets), (210.0, false, true), "other settings stay");
let mut app = app_with_widths();
app.ui = ui;
command(&mut app, false, &json!({"operation":"activate","panel":"tools"})).unwrap();
let workspace = app.ui.docking.as_ref().unwrap();
assert!(!workspace.layout.contains(&Panel::Library), "the Library panel stays closed");
assert_eq!(
columns(workspace, Side::End),
[(crate::state::RIGHT_WIDTH.default, vec![vec![Panel::Tools]]), (PRESETS_WIDTH, vec![vec![Panel::Presets]])]
);
}
}
#[test]
fn a_saved_dock_layout_round_trips() {
let mut workspace = default_workspace();
apply(&mut workspace, Action::ResizeColumn { panel: Panel::Tools, width: 333.0 }).unwrap();
apply(
&mut workspace,
Action::Move { panel: Panel::Presets, anchor: Panel::Library, placement: Placement::Group { below: true, height: None } },
)
.unwrap();
set_visible(&mut workspace, Panel::Library, false).unwrap();
let mut fields = saved_settings(serde_json::to_value(&workspace).unwrap(), Some(DOCKING_VERSION));
assert!(!drop_outdated(&mut fields));
let ui: crate::UiState = serde_json::from_value(Value::Object(fields)).unwrap();
let loaded = ui.docking.unwrap();
validate(&loaded).unwrap();
assert_eq!(loaded, workspace);
assert_eq!(serde_json::to_value(crate::UiState::default()).unwrap()["dockingVersion"], DOCKING_VERSION, "saves write the version");
}
#[test]
fn a_panel_whose_saved_place_is_gone_opens_in_its_dock() {
let root = split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Tools));
let mut workspace = Workspace::new(Layout { root: Some(root), floating: Vec::new() });
// Presets, the panel Library went back beside, is neither shown nor hidden.
let location: craft_ui::docking::Location<Panel> = serde_json::from_value(json!({
"floating": null,
"anchor": "presets",
"placement": {"Split": "Left"},
"size": {"FixedFirst": 280.0},
"stack": null,
"sibling": ["presets"]
}))
.unwrap();
workspace.hidden.push((Panel::Library, location));
validate(&workspace).unwrap();
set_visible(&mut workspace, Panel::Library, true).unwrap();
validate(&workspace).unwrap();
assert_eq!(columns(&workspace, Side::Start), [(crate::state::LEFT_WIDTH.default, vec![vec![Panel::Library]])]);
}
}
+2 -1
View File
@@ -2924,7 +2924,8 @@ mod tests {
/// accessibility and place fields reach the photo.
#[test]
fn info_fields_keep_typing_and_save() {
let mut h = demo([1300.0, 1000.0]);
// (tall enough for the place fields to show without scrolling)
let mut h = demo([1300.0, 1200.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
h.settle(SETTLE);
+14 -2
View File
@@ -49,7 +49,7 @@ pub mod sync;
pub mod tasks;
pub mod text_field;
pub mod theme;
pub mod titlebar;
pub mod updates_ui;
pub mod widgets;
pub mod window_place;
@@ -394,6 +394,8 @@ pub struct LightcraftApp {
pub perf: Perf,
/// macOS: the host draws the traffic lights over our top bar.
pub integrated_titlebar: bool,
/// The `movable` flag last given to the window (macOS, `craft_window::title_bar::sync_movable`).
pub window_movable: Option<bool>,
/// The host installed a native menu bar (no in-window menus then).
pub native_menu: bool,
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
@@ -409,6 +411,7 @@ pub struct LightcraftApp {
pub headless_host: bool,
/// Warnings to show one at a time (damaged settings files…, issue #103).
pub notices: Vec<String>,
pub(crate) updates_rt: updates_ui::Runtime,
/// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running.
pub quit_prompt: Option<QuitPrompt>,
/// Keyboard/focus/input state for the prompt above, including its one-frame creation barrier.
@@ -609,12 +612,14 @@ impl LightcraftApp {
perf: Perf::default(),
caches: Caches::default(),
integrated_titlebar: false,
window_movable: None,
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
text_focus: false,
headless_host: false,
notices: vec![],
updates_rt: Default::default(),
quit_prompt: None,
notice_input: Default::default(),
quit_confirmed: false,
@@ -1966,6 +1971,7 @@ impl LightcraftApp {
lightroom_import::tick(self, ctx);
recheck::tick(self);
tasks::poll(self, ctx);
updates_ui::tick(self, ctx);
self.preview_build_status(ctx);
self.save_status(ctx);
self.ai_mask_detail(ctx, now);
@@ -2123,7 +2129,7 @@ impl LightcraftApp {
// Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI
// phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract).
panels::library_problem::logic(self, ctx);
if self.fonts_ready {
if self.fonts_ready && !updates_ui::blocks_input(self) {
shortcuts::handle(self, ctx);
}
}
@@ -2626,6 +2632,7 @@ impl LightcraftApp {
panels::notices::hide_background_pointer(&ctx);
}
panels::dialogs::show(self, &ctx, notice_blocks);
updates_ui::show(self, &ctx);
panels::library_problem::show(self, &ctx, notice_blocks);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
@@ -2656,6 +2663,7 @@ impl LightcraftApp {
panels::notices::hide_background_pointer(&ctx);
}
panels::dialogs::show(self, &ctx, notice_blocks);
updates_ui::show(self, &ctx);
panels::library_problem::show(self, &ctx, notice_blocks);
export_task::poll(self, &ctx);
slideshow_export::poll(self, &ctx);
@@ -2668,6 +2676,10 @@ impl LightcraftApp {
panels::keyword_list::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
// AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't
// movable: it stops being movable while the pointer is on a bar control or a panel over it
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable);
if notice_blocks {
// Keep the real press/release state for the next pass: an ordinary click spans host
// frames, and resetting it above must only hide it from this pass's dialogs.
+1
View File
@@ -322,6 +322,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"app.systemInfo",
"app.openLogFolder",
"---",
"app.checkForUpdates",
"app.about",
],
),
+5 -1
View File
@@ -263,6 +263,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
// Settings ▸ Display
("app.displayProfile", "Display Profile", None, ""),
("app.about", "About LightCraft", None, "Help"),
("app.checkForUpdates", "Check for Updates…", None, "Help"),
("app.systemInfo", "System Info…", None, "Help"),
("app.openLogFolder", "Open Log Folder", None, "Help"),
// the frame-rate display (`frame_rate`): a debugging aid, never saved
@@ -892,7 +893,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.tool.clear();
}
let _ = app.session.end_interaction();
} else {
} else if app.ui.right == RightPanel::None {
let p = match std::mem::take(&mut app.ui.right_hidden) {
RightPanel::None => RightPanel::Edit,
p => p,
@@ -905,6 +906,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.tool = "remove".into();
}
}
// A right panel that is open but not showing (minimized, or behind another tab)
// keeps its mode; reveal_command brings it to the front.
Ok(json!({"open": app.ui.right != RightPanel::None}))
}
"view.beforeAfter" => {
@@ -1587,6 +1590,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::About);
Ok(Value::Null)
}
"app.checkForUpdates" => crate::updates_ui::check(app),
"photo.editInPhotoCraft" => edit_in(app, &ctx, p, true),
"photo.editInExternal" => edit_in(app, &ctx, p, false),
"photo.openOriginalInExternal" => open_original_in(app, &ctx, p),
+4 -33
View File
@@ -9,40 +9,11 @@ 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, bar: Rect, search_rect: Rect) {
use crate::titlebar::{Gesture, WindowState, command_for};
/// double-click acts as the system setting says. Made before the bar's widgets so they win the click.
fn window_handle(ui: &mut egui::Ui, bar: Rect) {
register(ui, "region:titlebar", bar);
let handle = egui::Id::unique("titlebar-handle");
let resp = ui.interact(bar, handle, Sense::click_and_drag());
let resp = craft_window::title_bar::empty_area(ui, bar);
crate::widgets::announce(&resp, crate::i18n::tr("Title bar"));
// 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::unique("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);
// The search field is drawn after this handle. Its hit-test response may not exist
// yet when the press is recorded; the press origin still identifies text selection.
let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p)));
let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search {
Some(Gesture::DoubleClicked)
} else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search {
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);
}
}
}
/// Draw the top bar: the titlebar drag area, the in-window menus when there is no native menu
@@ -78,7 +49,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let search_left = bar.center().x - sw / 2.0;
let sr = Rect::from_center_size(pos2(bar.center().x, full.center().y), vec2(sw, 28.0));
if app.integrated_titlebar {
window_handle(app, ui, bar, sr);
window_handle(ui, bar);
}
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
+18
View File
@@ -929,6 +929,24 @@ mod tests {
assert_eq!(h.app.ui.right, RightPanel::Edit);
}
/// F8 on a minimized Tools window opens it again in the mode it had.
#[test]
fn classic_f8_shows_a_minimized_tools_window_and_keeps_its_mode() {
use crate::{docking::Panel, state::RightPanel};
let mut h = library();
h.app.ui.view = crate::state::ViewMode::Detail;
h.app.ui.right = RightPanel::Edit;
h.settle(std::time::Duration::from_secs(10));
h.app.run("ui.dock", json!({"operation": "float", "panel": "tools", "rect": [400, 100, 320, 500]})).unwrap();
h.app.run("ui.dock", json!({"action": {"SetMinimized": {"panel": "tools", "minimized": true}}})).unwrap();
h.app.ui.right = RightPanel::Crop;
h.step();
assert!(!crate::docking::exposed(&h.app, Panel::Tools));
key(&mut h, "F8", false);
assert_eq!(h.app.ui.right, RightPanel::Crop);
assert!(crate::docking::exposed(&h.app, Panel::Tools), "the window opens");
}
#[test]
fn shift_culling_keys_advance_exactly_once() {
use lightcraft_catalog::{ColorLabel, Flag, PhotoId};
+4
View File
@@ -782,6 +782,9 @@ pub struct UiState {
pub window: Option<crate::window_place::WindowPlace>,
/// Shared panel arrangement; absent in legacy settings until their first layout.
pub docking: Option<crate::docking::Workspace>,
/// The arrangement generation `docking` was saved in ([`crate::docking::DOCKING_VERSION`]);
/// settings files are read through [`crate::docking::drop_outdated`].
pub docking_version: u32,
#[serde(default = "crate::i18n::default_language")]
pub language: crate::i18n::Locale,
/// The Build Previews run last announced (its identity, finished?).
@@ -1484,6 +1487,7 @@ impl Default for UiState {
UiState {
window: None,
docking: None,
docking_version: crate::docking::DOCKING_VERSION,
language: crate::i18n::default_language(),
preview_build_seen: None,
unsaved_seen: false,
+32 -8
View File
@@ -44,6 +44,30 @@ fn release(h: &mut Headless, pos: egui::Pos2) {
h.step();
}
#[test]
fn window_menu_reopens_closed_panels_in_their_dock_columns() {
use craft_ui::docking::Side;
let mut h = app();
let columns = |h: &Headless, side| {
crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), side).into_iter().map(|(_, groups)| groups).collect::<Vec<_>>()
};
let end = vec![vec![vec![Panel::Tools]], vec![vec![Panel::Presets]]];
h.app.run("panel.presets", json!({})).unwrap();
h.step();
assert_eq!(columns(&h, Side::End), end, "Presets opens on Tools' photo side");
dock(&mut h, json!({"operation":"close","panel":"tools"}));
assert_eq!(h.app.ui.right, RightPanel::None);
h.app.run("panel.edit", json!({})).unwrap();
h.step();
assert_eq!(columns(&h, Side::End), end, "Tools reopens at the window edge");
let width = crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start).first().unwrap().0;
dock(&mut h, json!({"operation":"close","panel":"library"}));
assert!(!h.app.ui.left_panel);
h.app.run("view.leftPanel", json!({})).unwrap();
h.step();
assert_eq!(crate::docking::columns(h.app.ui.docking.as_ref().unwrap(), Side::Start), [(width, vec![vec![Panel::Library]])]);
}
#[test]
fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
let mut h = app();
@@ -53,7 +77,7 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
let undo = h.app.session.undo.len();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.run(command, json!({})).unwrap();
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}");
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools), "{command}");
assert_ne!(h.app.ui.right, RightPanel::None);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
@@ -64,10 +88,10 @@ fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"}));
dock(&mut h, json!({"operation":"activate","panel":"library"}));
h.app.run("panel.presets", json!({})).unwrap();
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets));
dock(&mut h, json!({"operation":"activate","panel":"library"}));
dock(&mut h, json!({"operation":"open","panel":"presets"}));
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
assert!(crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Presets));
}
#[test]
@@ -81,7 +105,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"}));
dock(&mut h, json!({"operation":"activate","panel":"presets"}));
assert_eq!(h.app.ui.right, RightPanel::Crop);
assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools));
assert!(!crate::docking::exposed_in(h.app.ui.docking.as_ref().unwrap(), Panel::Tools));
let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap();
let id = h.app.session.active().unwrap();
let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap();
@@ -94,7 +118,7 @@ fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
}
assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop");
let workspace = h.app.ui.docking.as_ref().unwrap();
assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback");
assert!(crate::docking::exposed_in(workspace, Panel::Tools), "tool.done reveals Tools after the Canvas callback");
assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets));
assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library);
assert_eq!(h.app.session.active(), Some(id));
@@ -271,8 +295,8 @@ fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keywo
#[test]
fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() {
let mut h = app();
let divider = egui::Id::unique("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center();
let divider = egui::Id::unique("lightcraft-docking").with(("column-resize", Some(Panel::Library)));
let from = h.view.ctx.read_response(divider).expect("the Library column's divider is rendered").rect.center();
let before = h.app.ui.docking.as_ref().unwrap().layout.clone();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
@@ -303,7 +327,7 @@ fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() {
fn held_divider_replacement_route(route: &str) {
let mut h = app();
let divider = egui::Id::unique("lightcraft-docking").with(("split", Vec::<bool>::new()));
let divider = egui::Id::unique("lightcraft-docking").with(("column-resize", Some(Panel::Library)));
let from = h.view.ctx.read_response(divider).unwrap().rect.center();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+22
View File
@@ -169,3 +169,25 @@ fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() {
assert!(!h.app.window_is_fullscreen);
assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights");
}
// Scenario: a panel floating over the bar keeps a drag on it from moving the window (macOS's
// title-bar band would otherwise take the drag); a bar control does too
#[test]
fn a_panel_floating_over_the_bar_keeps_the_window_from_moving() {
let mut h = integrated();
let bar = widget(&h, "region:titlebar");
let empty = empty_spot(&h);
let hover = |h: &mut Headless, p: egui::Pos2| {
h.events.push(egui::Event::PointerMoved(p));
play(h);
craft_window::title_bar::window_movable(&h.view.ctx)
};
assert!(hover(&mut h, empty), "the empty bar moves the window");
let help = widget(&h, "icon:help").center();
assert!(!hover(&mut h, help), "a bar control");
let params = json!({"operation": "float", "panel": "tools", "rect": [bar.left(), bar.top() + 4.0, 330.0, 500.0]});
let r = h.request("engine.execute", json!({"command": "ui.dock", "params": params}), T);
assert_eq!(r["ok"], true, "{r}");
play(&mut h);
assert!(!hover(&mut h, empty), "the floating panel covers the empty bar there");
}
-76
View File
@@ -1,76 +0,0 @@
//! 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);
}
}
+188
View File
@@ -0,0 +1,188 @@
//! Software updates, background check at startup, and startup prompt modal.
use serde_json::Value;
use crate::LightcraftApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
#[cfg(not(target_arch = "wasm32"))]
repaint: Option<egui::Context>,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::LIGHTCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
manual_window_open: false,
status_message: None,
}
}
}
impl LightcraftApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
/// The update prompt owns the keyboard while it is open: shortcuts must not reach the library behind it.
pub(crate) fn blocks_input(app: &LightcraftApp) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.updater.prompt_open()
}
#[cfg(target_arch = "wasm32")]
{
let _ = app;
false
}
}
pub(crate) fn check(app: &mut LightcraftApp) -> Result<Value, String> {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
Ok(Value::Null)
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
Ok(Value::Null)
}
}
pub(crate) fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
rt.repaint = Some(ctx.clone());
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
let clean = app.session.unsaved().is_none();
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, Some(crate::i18n::tr("Checking for updates…").to_string())),
Status::UpToDate => (
true,
Some(
crate::i18n::tr("LightCraft {version} is currently the newest version available.")
.replace("{version}", env!("CARGO_PKG_VERSION")),
),
),
Status::Failed(e) => (true, Some(crate::i18n::tr("Could not check for updates:\n{error}").replace("{error}", e))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
egui::Window::new(crate::i18n::tr("LightCraft Updates"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
}
ui.add_space(8.0);
if ui.button(crate::i18n::tr("OK")).clicked() {
app.updates_rt.manual_window_open = false;
}
});
if !open {
app.updates_rt.manual_window_open = false;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_engine::Session;
#[test]
fn default_runtime_is_idle() {
let rt = Runtime::default();
assert!(!rt.manual_window_open);
#[cfg(not(target_arch = "wasm32"))]
{
assert!(!rt.startup_check);
assert!(!rt.updater.prompt_open());
assert_eq!(rt.updater.status(), &Status::Idle);
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn startup_prompt_shows_and_closes() {
let mut app = LightcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let release = craft_update::Release {
tag: "v99.0.0".into(),
version: craft_update::parse_version("99.0.0"),
name: "LightCraft 99.0.0".into(),
html_url: "https://github.com/storytold/lightcraft/releases/tag/v99.0.0".into(),
..Default::default()
};
app.updates_rt.updater.offer(release);
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui.ctx());
});
output.textures_delta.clear();
assert!(app.updates_rt.updater.prompt_open());
}
}
+59 -3
View File
@@ -1,4 +1,5 @@
//! Real library/tool panel moves preserve the selected tool and saved arrangement.
use craft_ui::docking::{DockArea, Hit, Node, Side};
use egui::{Event, Modifiers, PointerButton, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel};
@@ -63,7 +64,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
h.run_steps(3);
h.get_by_label("Float panel").click();
h.run_steps(5);
let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect;
let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect;
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Close panel").click();
@@ -71,7 +72,7 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
assert_eq!(h.state().ui.right, RightPanel::None);
h.state_mut().ui.right = RightPanel::Crop;
h.run_steps(4);
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect);
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.contains(&Panel::Tools)).unwrap().rect, rect);
let saved = serde_json::to_string(&h.state().ui).unwrap();
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
@@ -86,6 +87,61 @@ fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
}
#[test]
fn dragging_a_group_by_its_bar_moves_the_whole_group() {
let mut h = harness(None, 1400.0, 1.0);
h.state_mut().ui.presets = true;
h.run_steps(4);
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"presets","target":"tools","zone":"center"})).unwrap();
h.run_steps(6);
let bar = DockArea::<Panel>::hit_rects(&h.ctx, egui::Id::unique("lightcraft-docking"))
.into_iter()
.find_map(|(hit, rect)| matches!(hit, Hit::DragBar(Panel::Tools | Panel::Presets)).then_some(rect))
.expect("the Tools and Presets group draws its drag bar");
let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center();
drag(&mut h, bar.center(), target);
let mut library = start_dock(&h).into_iter().flatten().find(|panels| panels.contains(&Panel::Library)).unwrap();
library.sort_by_key(|panel| panel.id());
assert_eq!(library, [Panel::Library, Panel::Presets, Panel::Tools], "both panels of the dragged group joined Library");
assert_eq!(h.state().ui.right, RightPanel::Edit);
}
/// The start dock's columns from the window edge inwards, each as its groups' panels.
fn start_dock(h: &Harness<'_, LightcraftApp>) -> Vec<Vec<Vec<Panel>>> {
let layout = &h.state().ui.docking.as_ref().unwrap().layout;
let mut nodes: Vec<_> = layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => nodes.extend([&**first, &**second]),
Node::Dock { side: Side::Start, columns } => {
return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect();
}
_ => {}
}
}
Vec::new()
}
/// In a dock column, a group's top third stacks above it, its photo-side third adds a column
/// there and its middle joins its tabs.
#[test]
fn a_tab_dropped_on_a_dock_group_stacks_adds_a_column_or_joins_by_where_it_lands() {
let cases: [(fn(egui::Rect) -> egui::Pos2, Vec<Vec<Vec<Panel>>>); 3] = [
(|r| egui::pos2(r.center().x, r.top() + r.height() / 6.0), vec![vec![vec![Panel::Tools], vec![Panel::Library]]]),
(|r| egui::pos2(r.right() - r.width() / 6.0, r.center().y), vec![vec![vec![Panel::Library]], vec![vec![Panel::Tools]]]),
(|r| r.center(), vec![vec![vec![Panel::Library, Panel::Tools]]]),
];
for (at, expected) in cases {
let mut h = harness(None, 1400.0, 1.0);
let library = h.state().widgets.iter().find(|(id, _)| id == "panel:left_panel").unwrap().1;
let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
drag(&mut h, source, at(library));
assert_eq!(start_dock(&h), expected, "dropped at {:?} of {library:?}", at(library));
lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap();
assert_eq!(h.state().ui.right, RightPanel::Edit);
}
}
#[test]
fn invalid_docking_requests_preserve_layout_and_selected_tool() {
let mut h = harness(None, 1400.0, 1.0);
@@ -115,7 +171,7 @@ fn docking_renders_real_photo_panels_default_floating_and_redocked() {
}
h.run_steps(8);
settle_photo(&mut h);
h.state().ui.docking.as_ref().unwrap().validate().unwrap();
lightcraft_ui_egui::docking::validate(h.state().ui.docking.as_ref().unwrap()).unwrap();
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap();
+2 -2
View File
@@ -373,11 +373,11 @@ the pending name. `ui.dialog.confirm` starts the same import into the selected a
## Panel docking
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences as a craft-ui docking workspace (`docking`: `version`, `layout`, `hidden`, `saved`, `locked`); settings written before workspaces had a `version` load as version 1. These operations do not edit document contents.
Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`.
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|tab|left|right|top|bottom|above|below`; `Center`, `Left`, `Right`, `Top` and `Bottom` also parse, while `tab`, `above` and `below` are lowercase only). Center and tab group with the target; side zones split beside it; above and below put the panel in a new group above or below the target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. Both commands return `{"docking": workspace}`.
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`.
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -485,7 +485,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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, where the traffic lights are gone and the bar's left buttons move into their space (#1763). 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) |
| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `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 does what System Settings › “Double-click a window's title bar to” says (zoom or restore, minimize, nothing; craft-window's `title_bar`); buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). A press that starts on a button, the search field or a panel floating over the bar never moves the window (AppKit's `movable` is off while the pointer is there). 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
@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged |
| 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) |
| 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |
| 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged |
Binary file not shown.
+1 -2
View File
@@ -14,8 +14,7 @@ def _jsonl_path(arg):
if not path.is_file():
sys.exit(f"not a readable file: {arg}")
try:
with path.open("rb"):
pass
path.open("rb").close()
except OSError:
sys.exit(f"not a readable file: {arg}")
return path
+1 -1
View File
@@ -14,4 +14,4 @@ serde_json = { workspace = true }
sha2 = { workspace = true }
# precompressed web bundle (`cargo xtask web`); pure Rust
flate2 = "1"
brotli = { version = "8", default-features = false, features = ["std"] }
brotli = { version = "9", default-features = false, features = ["std"] }